85.1.39. pepsi-setup¶
provision the Pepsi database, signing keys and DNS for a deployment
- Manual section:
1
85.1.39.1.1. Name¶
pepsi-setup - install the schema, validate config, create DKIM keys and print DNS records.
85.1.39.1.2. Synopsis¶
pepsi-setup [GLOBAL-OPTIONS] [run [-r | –reset]]
pepsi-setup [GLOBAL-OPTIONS] schema [–backup-dir DIR] [–if-installed]
pepsi-setup [GLOBAL-OPTIONS] check
pepsi-setup [GLOBAL-OPTIONS] visualize
pepsi-setup –wizard [–force] [–expert SPEC] [–answers FILE] [–import MTA [–import-root DIR] | –no-import] [-c FILE]
pepsi-setup [GLOBAL-OPTIONS] questions
pepsi-setup [GLOBAL-OPTIONS] apply [–once] [–idle SECS]
pepsi-setup [GLOBAL-OPTIONS] apply –clear
pepsi-setup [GLOBAL-OPTIONS] bootstrap [–valid-for SECS] [–admin-url URL]
pepsi-setup [GLOBAL-OPTIONS] import [MTA] [–root DIR] [–out DIR] [–alias-style STYLE]
GLOBAL-OPTIONS include –no-certbot, –no-reverse-proxy, and -y/-n to answer prompts non-interactively (see Global Options).
85.1.39.1.3. Description¶
pepsi-setup is the administrative bootstrap tool for a Pepsi deployment. In a single invocation it:
Validates the configuration. It parses the
[pepsi]and[pepsi-ingress]sections (including listeners) and the[stage-*]pipeline — checking that a[stage-init]exists, that everyNEXT_STAGE/BOUNCE_STAGEresolves, and that each stage’s program configuration (including upstream-MTA routing) parses — and checks that every domain name is well-formed and that eachPUBLIC_IPentry is a valid IP address. It warns when a relay stage’sPUBLIC_IPis a non-public address (loopback, RFC 1918 / unique-local, link-local or CGNAT) — such an address can never send mail on the Internet, so with the trailing-allit makes outbound mail from that address family fail SPF (behind NAT, use the host’s public egress IP, not its LAN address) — and when a relay stage sets noPUBLIC_IPat all (the record degrades to a barev=spf1 -all). It also refuses the reserved.invalidplaceholder identity shipped in the sample configuration: if any ofHOSTNAME,ACCEPTED_DOMAINS,[pepsi] ARC_DOMAINor a stageSERVER_NAMEstill ends in.invalid(e.g.example.invalid), it stops with an error naming the offending values, because the sample was never edited — rather than provisioning keys and DNS for a domain that can never receive mail. Set those fields to your real domain, or runpepsi-setup --wizard. It warns about every option set in a known section that nothing reads — a misspelt option silently takes its default — suggesting the closest real option name, and about options renamed since an earlier release (see “Renamed options” in pepsi.conf(5)). Sections it does not know are left alone. If anything is wrong it reports the problem and exits non-zero without installing a schema, generating a key or publishing anything. (Validation is not literally the first thing that runs: the steps below that repair the configuration file — the reverse-proxy integration, the certbot paths, the proof-of-origin secret, the telemetry identifier and theMAILBOX_FS_QUOTAprobe — necessarily precede it, because validation is run against their result.) The operator-edited map files (below) are deliberately not part of this step, so an unrelated map typo can never block the run. If the pipeline uses apepsi-stage-detect-languagestage but that binary is not found on$PATH(it ships in its own large package, separate from the rest of Pepsi), the run warns and continues — installpepsi-stage-detect-languagebefore starting the service, or the detect-language stage will fail to spawn. Two further non-fatal warnings flag likely misconfigurations: apepsi-stage-block-languagestage with aBLACKLISTbut an emptyWHITELISTand a non-negativeTHRESHOLD(which bounces all neutral-language mail — see pepsi-stage-block-language(1)), and a non-progressingNEXT_STAGEcycle (a chain ofNEXT_STAGEedges that loops back on itself, so a message would circle untilMAX_LIFETIME; legitimate re-entry throughBOUNCE_STAGE/RESTART_STAGEis not flagged). It also warns when aTARGETSentry admits a uid below/etc/login.defsUID_MINon a stage whose setuid helper refuses such uids — pepsi-stage-relay-to-maildir(1), pepsi-stage-dot-forward(1), and pepsi-stage-auto-pay(1) inWALLET_MODE = local-user— since a recipient matched only by that entry fails in the helper on every message.The end-to-end cryptography settings are validated here too: the
[pepsi]CRYPTO_*options and the[pepsi-crypto]key-store section (see pepsi.conf(5)). Individually valid but jointly wrong combinations are errors —CRYPTO_SMIME_SHARED_KEY = yeswith an elliptic-curveCRYPTO_SMIME_ALGORITHM(one EC key doing both ECDSA and ECDH is rejected by several S/MIME clients), aCRYPTO_GENERATE_RSA_BITSbelowCRYPTO_MIN_RSA_BITS(the deployment would generate keys it then refuses), an implausibly smallCRYPTO_MIN_RSA_BITS, a non-positiveIDENTITY_VALIDITY_DAYS, and aKEY_WRAP_KEY_IDthat could not name its ownKEY_WRAP_SECRET_<ID>option. A[pepsi-crypto]section that says anything at all but has noKEY_WRAP_SECRETis likewise an error, and the message names the@inline-secret@fragment the secret should have come from together with its owner and mode: the@inline-secret@mechanism only warns about a fragment it cannot read, so an unreadable one is otherwise indistinguishable from an unset option. A secret shorter than 16 characters is refused — it is the single key that opens every stored private key. A deployment that does no end-to-end cryptography configures none of this and is not nagged about it.For each pepsi-stage-encrypt(1) stage it also checks the pEp preset (
ENABLE_PEP) and the key self-service. It warns whenENABLE_PEPis on but[pepsi-crypto] AUTO_CREATE_IDENTITY = no(a valid site veto, but then no sender gets a key automatically; sayENABLE_PEP = noto make it explicit) or noKEY_WRAP_SECRETis configured (no sender can be given a key); whenRESPONSE_STAGEis unset (users are not told when a new key is registered for their address, and the e-mail key commands topepsi-keys@<domain>are off); and when[pepsi-ingress] USERNAME_MAPgrants a wildcard, naming those accounts: they may send as any matching address but can never register their own key for one. It refuses aRESPONSE_STAGEnaming no stage, and, whenRESPONSE_STAGEis set, aTEMPLATE_DIRlacking thekey-registered.en.bodyorkeys.en.bodyfallback template.Setup also repairs an orphaned fragment. The secret and the
@inline-secret@directive that pulls it in are two halves of one statement living in two files, and the fragment outlives the configuration that referenced it — so a regenerated or hand-writtenpepsi.confleaves a deployment wheresecrets.d/pepsi-crypto.secretis present and correct, nothing loads it, and every message through pepsi-stage-encrypt(1) fails. Neither half looks wrong on its own, and re-running asrootdoes not help: root can read the fragment, but the directive is what pulls it in. When the fragment exists and the configuration does not reference it, setup adds the directive and says so — that rotates nothing and writes no key material, so it is safe even when the secret itself is unreadable. What it cannot recover is a non-defaultKEY_WRAP_KEY_IDthat went with the lost file, so the warning names the id it asserted; set it by hand if the key was stored under another.Prepares the operator-edited map files. On every run it walks the configured lookup tables — the
[pepsi-ingress] USERNAME_MAPand eachpepsi-stage-aliasesstage’sALIASESmap. A file that does not exist is created from a commented starter template (mode0644) that documents the file’s purpose and shows the syntax, so the operator finds a self-documenting file in the right place. A file that does exist is syntax-checked with the same parser the owning component uses at run time, so a malformed line (or an address/domain typo) is surfaced with its line number. This step is purely advisory: a bad map, or a template that cannot be written, is logged as a warning and never aborts the run (the run-time parsers are themselves lenient, warning and skipping a bad line). In--wizardmode this runs only afterpepsi.confhas been written, so a map problem never prevents the wizard from writing the configuration.Integrates with an existing front HTTP server. If another web server (nginx or Apache) is already listening on ports 80/443, pepsi-httpd cannot bind 443 and
certbot --standalonecannot bind 80. Rather than fight for the ports, pepsi-setup detects the front server (viass), confirms it does not already serve any of themta-sts.<domain>host names, and then (a) keeps[pepsi-httpd-listener-https]socket-activated but plain-HTTP (SERVE = systemd,MODE = plain), stripping any direct-bindMODE = tls+TLS_CERT/TLS_KEYand the now-unneeded[pepsi-httpd-cert-*]sections, and drops apepsi-httpd.socketoverride (/etc/systemd/system/pepsi-httpd.socket.d/10-reverse-proxy.conf) that rebindsListenStreamfrom443to a UNIX socket at/run/pepsi/httpd.sock(SocketGroup=the front server’s group:www-dataon Debian,nginx/apache/httpdelsewhere), and (b) writes a reverse-proxy site (pepsi-mta-sts) into the front server’ssites-available(enabling it insites-enabled;conf.dfor non-Debian nginx) that terminates TLS for those host names and forwards them to the socket. The front server now terminates TLS, so the certificates (this step and the SMTP MX certificate of the next step) are obtained through that server’s certbot authenticator plugin (--nginx/--apache) rather than--standalone. This step is a no-op when no front server is present, and is skipped entirely with--no-reverse-proxy(in which case pepsi-httpd binds 443 directly). It is idempotent: once thepepsi-mta-stssite exists, re-runs only re-assert the socket listener. The integration is deferred (with an actionable message in the end-of-run summary, leaving pepsi-httpd in its direct-bind configuration) rather than aborting when the front server already serves one of themta-sts.<domain>names (a conflict you must resolve), when it is a server pepsi-setup cannot yet automate (Caddy, lighttpd, Apache Traffic Server — wire the proxy by hand or pass--no-reverse-proxy), when it is not running as root, or when the MTA-STS certificate itself cannot be obtained (the reverse-proxy site references the live certificate, so it cannot be written until the certificate exists) — in each case re-runpepsi-setup runonce resolved. Thepepsi-httpdservice must provide the socket directory (the shipped systemd unit does so withRuntimeDirectory=pepsi) and the front server’s user must be able to reach the group-shared socket.Provisions TLS certificates. For every listener that terminates TLS — the
[pepsi-ingress-listener-*]sockets withMODE = tls/starttls, the TLS[pepsi-httpd-listener-*]sockets, and each[pepsi-httpd-cert-*]SNI certificate — that has noTLS_CERT/TLS_KEYset, it fills the options in with the standard certbot layout (/etc/letsencrypt/live/<host>/fullchain.pemandprivkey.pem), writing them into the configuration file in place (comments are preserved). The<host>is the[pepsi-ingress] HOSTNAMEfor SMTP/HTTP listeners and the certificate section’s firstSNIhost for[pepsi-httpd-cert-*]; listeners sharing a host share one certificate.The HTTPS listener’s certificate additionally covers
openpgpkey.<domain>for every domain inACCEPTED_DOMAINS, because that is where the Web Key Directory’s advanced method is fetched from (pepsi-httpd(1)), and — when[pepsi-autoconfig]turns mail autoconfiguration on —autoconfig.<domain>as well, which is the first URL a mail client tries. They are added to the listener’s own certificate rather than given a[pepsi-httpd-cert-*]section of their own, since that certificate is the fallback served whenever SNI matches nothing — so one certificate covers both the SNI hit and the fallback.The ingress listeners’ certificate covers every MX host an MTA-STS policy permits — the
[pepsi] MTA_STS_MXset across all served domains — for a reason with no counterpart on the HTTPS side. RFC 8461 §4.1 requires the certificate an MX presents to match the name theMXrecord gave the sender, and pepsi-ingress(1) serves exactly one certificate per listener: it records the client’s SNI but never selects on it. A host reachable asmx.example.organdmail.example.nettherefore cannot answer with two certificates; it needs one certificate carrying both names. Wildcardmxentries (*.example.net) are skipped with a warning — HTTP-01 cannot satisfy a wildcard, and passing one to certbot would fail the whole request including the names that would have worked — so a deployment publishing one must supply that certificate itself.Then, for each such host, pepsi-setup runs
certbot certonly --standalone(HTTP-01 over TCP/80, registered without an email) when the certificate is not on disk — and, when it is, reads the Subject Alternative Names out of it and re-runs certbot (--expand, same--cert-name) if they do not cover every name above. That matters because a certificate’s name set is not fixed at first issuance: adding a served domain, turning on autoconfiguration or naming a new MX host all widen it. A certificate whose names cannot be read is left alone rather than re-issued, since “I could not tell” must not spend a rate-limited issuance. Every name is checked against DNS, not just the certificate’s own--cert-name: certbot validates each-dseparately and fails the whole request if one of them cannot be reached.Which certificates pepsi-setup may drive certbot for is decided by comparing the configured
TLS_CERT/TLS_KEYagainst the layout paths it would itself have written for that host. It cannot be “isTLS_CERTset?”, because a successful first run sets it — after which every listener would look operator-configured and no certificate could ever be widened again. Paths equal to the layout are pepsi-setup’s to keep current; anything else is a certificate the operator chose and is never touched, and a half-configured section (one of the two options set) is left to the strict validation, which reports it better. A certificate that is missing and cannot be obtained is deferred rather than fatal: setup continues (installs the schema, generates keys, prints DNS records) and lists every deferred certificate in a summary at the very end, with the concrete next step and a reminder to re-runpepsi-setup runonce the obstacle is cleared. Re-running is idempotent and only completes what is still missing. A certificate is deferred when--no-certbotwas given,certbotis not installed, pepsi-setup is not running as root (so it cannot bind TCP/80), the host’s DNS does not resolve to a configuredPUBLIC_IP(so the challenge would fail), orcertbotitself fails (for example a transient ACME-server incident or a rate limit — a plain re-run then retries). The DNS case distinguishes a name with no A/AAAA record from one that resolves elsewhere (reporting the addresses it found versus the configuredPUBLIC_IPs). Because the certificate file is then absent, the DANE/TLSA record for that host cannot be computed, so the DNS output prints a comment saying the record is still missing and how to provision it. Only a genuinely unusable invocation is fatal: the configuration file being unwritable, or — when no configuration file exists at any default location and no--configwas given — its path being unknown, so the auto-filled paths cannot be persisted. Note that a server whose TLS listener still lacks a certificate will not start until the certificate exists.Gives the servers access to their key material. The TLS servers (pepsi-ingress, pepsi-httpd) run as unprivileged users and cannot read certbot’s root-only
/etc/letsencrypt/{live,archive}tree, so a listener would otherwise fail to start with a bare permission denied. Three mechanisms address this, applied on every run:systemd service credentials — the primary mechanism wherever systemd runs the servers. For each server, pepsi-setup writes
/etc/systemd/system/<unit>.d/10-tls-credentials.confnaming every certificate and key that server reads as aLoadCredential=entry. systemd then 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 that one service), where Pepsi’s TLS loader looks up each configured path before falling back to reading the path itself — so the file permissions stop mattering altogether. The drop-in is regenerated from the configuration on every run and lists only files that exist, because systemd refuses to start a unit whose credential source is missing; re-runpepsi-setup runafter adding, moving or removing a certificate. When the drop-in changes, pepsi-setup reloads systemd andtry-restarts the affected server (only if it is running) so the new material takes effect.POSIX ACLs and a certbot deploy hook — the fallback for a deployment systemd does not run, and the vehicle that handles renewals. For every certbot-managed certificate the configuration references, pepsi-setup grants those service users read+traverse access with POSIX ACLs (
setfacl), and installs a certbot deploy hook at/etc/letsencrypt/renewal-hooks/deploy/pepsi-cert-access.shthat re-applies the grant on every future issuance/renewal (certbot rotatesarchive/<host>/privkeyN.pem, so a one-time grant would not survive; the hook’s default ACLs make freshly-created files inherit access too) and restarts the Pepsi servers, which is what actually puts a renewed certificate into service — each server reads its material once at start-up, and systemd materialises credentials when the unit starts. This half is best-effort: it needs root,setfacl(theaclpackage), and an ACL-capable filesystem — a missing prerequisite is deferred with the fix, not fatal. Certificates outside certbot’s tree (operator-setTLS_CERT/TLS_KEY) are not managed and only warned about; grant their read access yourself.Verification — for anything not covered by a credential, pepsi-setup does not assume the grant worked. It forks, becomes the service user and tries to open the file, so a certificate that is still unreadable (an ACL silently ignored by the filesystem, a root-only parent directory, an operator-set path nobody granted) is reported as a deferred step naming the exact file, user and unit — instead of surfacing later as a server that refuses to start.
Ensures Dovecot reachability. When local delivery via LMTP or submission authentication via Dovecot SASL is configured, pepsi-setup probes the configured socket as the service user that will connect to it — the dispatcher’s
pepsifor LMTP,pepsi-ingressfor SASL. This catches the common case of a socket that exists but is unreachable: Dovecot’s shared/run/dovecot/auth-clientis0600 dovecotand cannot be opened bypepsi-ingress. If a socket is missing or unreadable and a local Dovecot is present, pepsi-setup (running interactively as root) offers to install/etc/dovecot/conf.d/10-pepsi.conf— a dedicatedunix_listenerowned by the Pepsi service user (mode = 0600, so the shared socket’s permissions are left untouched) — then validates the merged configuration withdoveconfand reloads Dovecot. When it cannot (not root, no terminal to ask at,doveconfrejecting the result, or no local Dovecot), it prints the drop-in and defers so you can deploy it by hand. Pepsi’s defaultSASL_PATHis this private socket,/run/dovecot/auth-client-pepsi. This check runs on everypepsi-setup run, not only under--wizard.-y/--yes-to-allinstalls the drop-in without prompting;-n/--no-to-allskips the install and just prints it — either makes the run fully non-interactive.Checks for another MTA on the SMTP ports. Pepsi is rarely the first mail server on a host — Debian pulls in Postfix or Exim as the default
mail-transport-agent. If one of them is still listening, pepsi-ingress cannot bind and the failure is silent in a particularly misleading way: under socket activation it ispepsi-ingress.socketthat fails (systemctl status pepsi-ingress.socketreportsResult: resources), mail keeps being delivered by the other server using its alias and virtual maps, and every change topepsi.conftherefore appears to have no effect whatsoever. pepsi-setup reads the kernel’s listener table (/proc/net/tcp) for each port the[pepsi-ingress-listener-*]sections need — the configuredPORTfor aSERVE = tcplistener, and the shipped socket unit’sFD_INDEXmapping (0 → 25, 1 → 465, 2 → 587) for aSERVE = systemdone — and names the offending process (with its pid) when the holder is neithersystemdnor Pepsi itself. This is advisory: it is deferred to the end-of-run summary rather than fatal, since an operator mid-migration may deliberately want the old MTA up a while longer. Resolve it by stopping and disabling the other server (systemctl disable --now postfix) and then startingpepsi-ingress.socket.Checks that the resolver validates DNSSEC. In the same phase, and only when the configuration asks for DANE, pepsi-setup asks the system resolver a question it knows the answer to and looks for the
ADbit. Pepsi trusts a validating resolver rather than validating DNSSEC itself, so a resolver that never setsADmakes DANE and DANE-based key discovery find nothing at all, silently. Also deferred, not fatal.Installs the database schema. Every Pepsi component shares one database (the
[pepsi-postgres]section) and the onepepsischema; pepsi-setup is the sole installer. The schema is a single patch series (pepsi-NNNN.sql) with oneprocedures.sql, applied from[pepsi-postgres] SQL_DIR(default${DATADIR}/sql). The operation is idempotent and may be re-run after upgrades. The other components have no schema-initialization command of their own.Provisions the database login roles. Each Pepsi service account gets a same-named PostgreSQL login role, authenticated over the local socket by peer authentication, and the grants it needs on the
pepsischema. The pipeline rolepepsi(andpepsi-crypto) gets the whole schema, less the carve-backs below; the three services that are not the pipeline are then taken back to what an audit of their code says they use, as the very last step, and setup refuses to finish if PostgreSQL disagrees:pepsi-ingressINSERTintopepsi.workqueueandpepsi.workqueue_body(plusSELECTof the idsworkqueue_addreturns and of the two generated size columns,header_octetsandoctets, that the queue admission check sums),SELECTonconfig_overrideandmailbox_quota,INSERTon the two append-only logs. It cannot read any message already queued.pepsi-telemetryIts own
pepsi.telemetrytable.pepsi-httpdEverything the console, the portal, the list site and the archive use, but on
pepsi.workqueueonly the envelope,stateand routing columns — neverheadersor the body, and it cannot repoint a message’sbody_id; onpepsi.workqueue_bodyonlyINSERT(the web forms send mail) andDELETE(deleting a message drops the body only it carried; the foreign key refuses a body another message still references), neverSELECTof the body — and nothing at all onsettings,whitelist,vacation_reply,payment_request_reply, the secretary tables,telemetry,origin_nonceorauto_pay_spend; the statistics, quota, DNS and TLS-report tables and the telemetry daemon’s liveness row (telemetry_client) read-only.
The default privileges for tables a later patch creates are revoked from
pepsi-ingressandpepsi-telemetryas well. See the manual’s “Security model” for the reasoning and its “Test Suite” for how the boundary is tested.Three further roles are deliberately narrower still:
pepsi-whitelistGranted
SELECT,INSERTandDELETEonpepsi.whitelistand nothing else, so a program every local user may execute (pepsi-whitelist(1), installed setuid) cannot reach the message queue, the statistics or the signing keys.pepsi-keydiscThe key-discovery services (pepsi-keydisc(1)), which parse key material fetched from the open internet and are therefore the likeliest process here to be broken into. This is the narrowest role in the deployment:
SELECT/INSERT/UPDATE/DELETEonpepsi.peer_key(filling that cache is its whole job, and the conflict rule has to be able to replace a row),SELECT/INSERT/UPDATEonpepsi.key_request,EXECUTEon thekeydisc_*functions and the twocrypto_*helpers they call — and, onpepsi.workqueue,SELECTplus a column-levelUPDATE (status, timeout).Those two columns are the interesting part. They are exactly enough to move a message parked on an address from
pausedback topending, which is whatkeydisc_resolveandkeydisc_sweepdo (plainSECURITY INVOKERfunctions, so the reach has to be granted explicitly rather than inherited). With a table-level grant this account could rewrite a message’s recipients or its body; with these two columns it can only wake a message up. It has no access topepsi.crypto_identityat all.pepsi-cryptoThe account that custodies end-to-end private key material. It receives the ordinary pipeline grants across the schema plus the
pepsi.crypto_identity.private_wrappedcolumn — and it is the only role that does.
That last column is what the exercise is for. Immediately after the blanket grants, pepsi-setup revokes the table-level
SELECT/INSERT/UPDATEonpepsi.crypto_identityfrom every ordinary service role —pepsi(the dispatcher and every stage worker),pepsi-ingress,pepsi-httpdandpepsi-telemetry— and grants the same three back column by column for every column exceptprivate_wrapped. So those roles can read, write and publish an identity’s public half, and a compromise of an unrelated stage yields nothing more, even with full database access under that role. (DELETEhas no column granularity in PostgreSQL and stays whole: a stage that can delete an identity row can deny service, but it still cannot read a key.) The column list is read back frominformation_schemarather than hard-coded, so a column added later is covered automatically and the grant cannot go stale against the schema. Like everything else in this step it is idempotent, and it is deliberately re-applied on every run, because the blanket grants re-add the table-level privilege each time.The wrapped column is only half of the custody model; the key that opens it lives in a
secrets.dfragment (below). Note also that PostgreSQL peer authentication keys off the effective uid, not the gid — so a program that must be this role has to be this account, which is what pepsi-keys(1) arranges around each database call. See pepsi-keys(1).Generates signing keys. For each domain Pepsi is authoritative for — the union of
[pepsi-ingress] ACCEPTED_DOMAINS, the domains of each stage’sSERVER_NAME/POSTMASTER, an explicitSIGNING_DOMAIN, the[pepsi-srs] SRS_DOMAINof an SRS stage, a self-service stage’s fixedRESPONSE_FROM, and the[pepsi] ARC_DOMAINif set — it creates an RSA-2048 and an Ed25519 DKIM private key underKEY_DIRif they do not already exist. Existing keys are never overwritten, and key files are created mode0600in per-domain directories created mode0700.Prints DNS records. It writes to standard output, in BIND zone-file format, the records to publish for each domain: the DKIM public keys (
<selector>._domainkeyfor RSA and<selector>-ed25519._domainkeyfor Ed25519), an SPF policy (v=spf1 …) built from thePUBLIC_IPaddresses of every relay stage (unioned; see below), a DMARC policy (_dmarc.<domain>TXT — see below), and an MTA-STS record (_mta-sts.<domain>TXT, unlessMTA_STS_MODE = none) so other senders use validated TLS for our inbound mail. The MTA-STS policy file itself is served over HTTPS by pepsi-httpd(1) athttps://mta-sts.<domain>/.well-known/mta-sts.txt(itsmxset comes from[pepsi] MTA_STS_MX, falling back to the[pepsi-ingress] HOSTNAME), so pepsi-setup prints only the_mta-stsTXT record — whoseidis derived from that domain’s own policy, so two domains with differentmxsets advertise different ids — and a reminder to pointmta-sts.<domain>at the server. Unlike every other record here, the MTA-STS one is printed only for the[pepsi-ingress] ACCEPTED_DOMAINS. DKIM, SPF and DMARC authenticate an identity this host sends as, so they cover the wider set above; MTA-STS is a promise about mail coming in, and only an accepted domain has one kept — pepsi-httpd(1) answers/.well-known/mta-sts.txtfor those hosts alone, and the certificate (and any front-server site) is obtained for those names alone. A domain that is only a sending identity — a relaySERVER_NAME, an explicitSIGNING_DOMAIN, the[pepsi-srs] SRS_DOMAIN— but that still has a published_mta-stsTXT record gets a; DELETE:comment instead: what it advertises is a policy no component serves, so the request falls through to whatever else answers on that address. When[pepsi-tlsrpt] RUAis set, a TLSRPT record (_smtp._tls.<domain>TXT,v=TLSRPTv1; rua=…) is also printed, so other senders report their TLS results toward our domains to the advertised address (see pepsi-tlsrpt(1)). Long RSA records are split into multiple character-strings automatically. Finally, for each TLS-terminating ingress listener it suggests a DANE/TLSA record (RFC 7672) under the MX hostname (_<port>._tcp.<HOSTNAME>IN TLSA 3 1 1 <hash>), where the association data is the SHA-256 of the served certificate’sSubjectPublicKeyInfo— the3 1 1(DANE-EE / SPKI / SHA-256) form, so the record survives certificate renewals that reuse the key pair. Publish these only in a DNSSEC-signed zone; the port-25 record is the one that authenticates inter-MTA delivery (submission ports are flagged as a note, since senders there use the PKIX certificate). For completeness, equivalent3 1 1TLSA records are also printed for the pepsi-httpd(1) HTTPS listeners (per SNI host, plus the fallback certificate underHOSTNAME), but these are marked OPTIONAL: HTTPS clients (browsers, MTA-STS policy fetchers) do not perform DANE validation, so the records can be safely omitted and are provided only for an administrator who wants to pin the web certificate.Two further blocks concern end-to-end key publication (see pepsi-keys(1)). First, for every served domain whose
openpgpkey.<domain>host does not already resolve, a reminder to publish an A/AAAA record for it: the Web Key Directory’s advanced method — the one every current client tries first — is fetched from that name, so it must point at the pepsi-httpd(1) host. A domain that already publishes keys is flagged in the output and warned about in the log, because for it the advanced lookup is failing today rather than merely unconfigured. (The direct method on the apex works without it, so this is a degradation, not a breakage.) Second, anOPENPGPKEY(RFC 7929) orSMIMEA(RFC 8162) record for each published, active identity in the key store, under the RFC’s hashed owner name (SHA-256 of the local part truncated to 28 octets, hex — not the Web Key Directory hash, which is z-base-32 SHA-1 of the lower-cased local part). These are not compared against live DNS: each is a few hundred bytes whose owner name is a per-identity hash, so probing would cost one query per user for records the operator pastes wholesale. The listing stops after twenty identities; usepepsi-keys dns <address>for a specific one. Publish key records only in a DNSSEC-signed zone — an unsigned one is a key from whoever can answer for the zone.A last block concerns mail autoconfiguration. When
[pepsi-autoconfig]is on, every served domain’sautoconfig.<domain>host that does not already resolve is listed with a reminder to point it at the pepsi-httpd(1) host. Unlike the Web Key Directory reminder above this is close to a total failure rather than a degradation: a client trieshttps://autoconfig.<domain>/mail/config-v1.1.xmlfirst, and the/.well-known/form on the apex — which needs no new record — is optional in the draft, so a client that does not try it simply finds nothing.The SPF record is built from
PUBLIC_IP, which is a per-stage option read from every relay stage — detected by itsPROGRAM(pepsi-stage-relay-to-internet(1) or pepsi-stage-relay-to-smarthost(1)), never by the section name — and unioned. A deployment may thus have several relay stages, each with its ownPUBLIC_IP(a pepsi-stage-relay-to-internet stage lists this host’s own sending IPs; a pepsi-stage-relay-to-smarthost stage lists the smarthost’s egress IPs, since mail leaves the internet from there). If a relay stage exists but none setsPUBLIC_IP, the record is a barev=spf1 -all— which forbids all hosts from sending as your domains and makes your own outbound mail fail SPF — and pepsi-setup logs a warning; a receive-only deployment (no relay stage) is not warned. The unioned record is suggested identically for every served domain: correct, but broad. In special setups where different domains send from different hosts, a hand-written per-domain SPF record listing only that domain’s own sending IPs is tighter and still correct; pepsi-setup does not compute that grouping automatically and prints a note to that effect alongside the suggested records.One SPF case is called out in the zone output rather than merely suggested into: a domain that already publishes two
v=spf1records. RFC 7208 §4.5 makes that a permanent error — no SPF policy applies, rather than the first record winning — and the repair is to merge them, so the suggested record is printed with a note saying that adding it without removing the others changes nothing. A leftoverv=spf1 include:<old provider>is the commonest thing an MTA migration leaves behind.The DMARC record is the one pepsi-setup cannot derive: which policy a domain wants is an operator decision, not a consequence of the configuration. So this block is about the record being usable, and it behaves differently depending on what is already published:
Nothing published.
v=DMARC1; p=noneis suggested, with a comment explaining that it only monitors and should be tightened toquarantineorreject, and thatrua=mailto:<address>is what makes the reports arrive that tell you whether tightening it is safe. (Pepsi does not process inbound DMARC aggregate reports; send them somewhere that does.)A valid record. Nothing is printed, exactly like the other records.
A record that receivers would ignore. The reason is printed as a comment and logged as a warning, and no replacement value is offered. This is deliberate: a record that fails to parse is still a statement of intent, and a malformed
p=rejectmust be repaired where it stands rather than replaced by thep=nonethis tool would otherwise suggest — fixing a typo should not silently downgrade a domain’s policy.
Validity is judged strictly, tag by tag, against RFC 7489 §6.4 rather than by looking for substrings, because a DMARC record fails silently: nothing bounces, no receiver complains, and the domain simply stops being protected while everyone believes it is. In particular a single missing
;— say… p=reject; aspf=s adkim=s— is perfectly good generic tag-list syntax (RFC 6376 §3.2 allows spaces and=inside a tag value), so a loose reader sees the tagaspfwith the values adkim=sand accepts it; only the DMARC-specific value grammar rejects it. Where the offending value contains something shaped like the next tag, the diagnostic says so by name (tags are separated by ';', and there is none before 'adkim='). The other things reported are a second DMARC record at the same name (RFC 7489 §6.6.3: receivers discard the whole set), a repeated tag (RFC 6376 §3.2, where which value wins is undefined), a missingp=tag, and anrua=/ruf=address written without itsmailto:scheme. Everything pepsi-setup calls valid is cross-checked in the test suite against the very parser pepsi-ingress(1) applies to inbound mail, so a record blessed here cannot be one a receiver throws away; the checks above are deliberately stricter than that parser, since a record can parse and still not be the policy its author published.Before suggesting each TXT record (DKIM, SPF, DMARC, MTA-STS, TLSRPT), pepsi-setup first queries live DNS (best-effort): a record that is already published with the correct value contributes nothing to the output — not even its per-domain section header — so only the records that genuinely need (re-)publishing are printed. The
mta-sts.<domain>reminder to publish an A/AAAA (or CNAME) record is likewise emitted only when that host does not already resolve, and the DANE/TLSA block is printed only when a TLS-terminating ingress listener exists. When nothing needs changing, the whole zone dump is suppressed and a singlesetup: DNS records checked and they are OKline is logged instead, so re-running over an already-configured zone is silent. The probe is non-fatal — when DNS is unreachable (or a resolver cannot be built) every record is treated as a change and printed. The DMARC block is the exception in that last respect: with no answer from DNS there is nothing to compare against and no local value to fall back on, so it stays silent rather than telling a domain that may already be correct to publish ap=none. (Use the check subcommand for live verification of the published TXT records.)“Already published with the correct value” means correct at every authoritative nameserver of the zone, not just in the system resolver’s answer. For each TXT record pepsi-setup also finds the zone’s
NSset and asks each server directly, without recursion: a secondary still serving an old key, a delegated server that no longer serves the zone (a lame delegation) or one that does not answer at all makes receivers see the right record only some of the time, so the record is printed with a; NOTE:line naming each server that disagrees (and a warning is logged). A wrong answer from the resolver that every authoritative server contradicts is marked as a stale cache. When no authoritative server can be reached — a network that lets only the local resolver out on port 53 — a warning is logged once and the resolver’s answer alone decides, as before.A DKIM selector record is judged the way a verifier reads it (RFC 6376 §3.6.1), not by its
p=alone: exactly one key record at the name,v=(if present) first andDKIM1, ak=naming the key type (an Ed25519 key with nok=tag fails everywhere, since the tag defaults torsa), noh=/s=restriction excludingsha256/email, not=y(testing mode, under which a signature counts for nothing) and ap=equal to the local key. Only the selectors[pepsi] DKIM_ALGORITHMSsigns with are printed and checked, plus the one the ARC seal needs on theARC_DOMAIN. Next to the Ed25519 record — and as a log line when every record is already correct — pepsi-setup notes that Gmail, Microsoft 365 and Yahoo do not verify Ed25519 signatures and that Google’s DMARC aggregate reports list them asfail: expected and harmless, since the RSA signature supplies DMARC’s aligned pass, and removable withDKIM_ALGORITHMS = rsa.In the same phase pepsi-setup also checks the reverse DNS (PTR) of each pepsi-stage-relay-to-internet
PUBLIC_IP— this host’s own egress addresses (a pepsi-stage-relay-to-smarthostPUBLIC_IPis the smarthost’s, so it is skipped). Each address is judged against the name the owning stage announces, itsSERVER_NAME(falling back to the ingressHOSTNAMEwhen a stage sets none), because that is the pair a receiver compares. APUBLIC_IPis correct only when itsPTRexists, forward-confirms, and names exactly that host; every other outcome is a warning:no PTR record — a strong spam signal at many receivers;
a PTR that is not forward-confirmed — the
PTRhost name does not resolve (A/AAAA) back to the same IP, so the round-trip check (forward-confirmed reverse DNS, FCrDNS) many MTAs apply fails and they treat the IP as having noPTRat all;a generic / dynamically-assigned-looking PTR — a name that embeds the IP or carries a residential-pool token (
dynamic,dsl,pool…), which receivers often penalise;a PTR naming a different host than the ``EHLO`` name — the record is perfectly well-formed and forward-confirmed, it simply names another host. A large family of receivers refuses the session outright with HELO host does not match rDNS. This fires even when the ``PTR`` name is one of your own
ACCEPTED_DOMAINS: a machine that answers to several names (sayexample.organnounced inEHLOwhile its address reverse-resolves toexample.net) passes every other check here and still has its mail refused;a PTR that could not be looked up at all — an undelegated reverse zone or unreachable DNS. Correctness could not be established, and an address with no usable
PTRis refused or junked by many receivers, so this is reported rather than passed over silently.
Each warning carries concrete remediation. Because reverse DNS for a
PUBLIC_IPlives in the IP owner’s zone (in-addr.arpa/ip6.arpa), the usual fix is to ask your ISP or hosting provider to set thePTRto the announced name; if you run the reverse zone yourself, the missing-PTR message prints the exact… IN PTR …record to publish. For a name mismatch where thePTRis already one of your own domains, the message offers the other direction too — adopt thePTRname as this host’s identity ([pepsi-ingress] HOSTNAMEand every stage’sSERVER_NAME, with the MX record and TLS certificate following it) — since that fix needs nobody else’s cooperation. The two names must denote one host; sharing a domain is not enough. The wizard’s hostname default is taken from the samePTRlookup (see –wizard below), so an interview-generated configuration starts out agreeing with reverse DNS.DNS as the Internet sees it. Every check above is made from this host, which is the one place a zone served from its own network always looks healthy: behind NAT, a query from inside for a reverse zone whose nameserver is this very machine is answered by the LAN, by the router or by the host itself, while the Internet times out at a router that forwards port 25 but not port 53 — or reaches a nameserver that serves a smaller zone than its parent delegates, which resolvers that minimise their queries (RFC 9156) receive
REFUSEDfor. Receivers then defer every message with cannot find your reverse hostname, and the local check says thePTRis correct.So pepsi-setup asks again through the public resolvers named by
[pepsi] PUBLIC_RESOLVERS(by default Google, Cloudflare and Quad9, each over IPv4 and IPv6; see pepsi.conf(5)): thePTRof everyPUBLIC_IP, forward-confirmed through the same resolver; each domain’sMXset and the TXT records the check subcommand verifies; and the addresses of the ingressHOSTNAME. All of them are asked, because they differ — in the address family they use to reach a nameserver and in how they walk a delegation — and a zone that is reachable only over IPv6, or lame only for a minimising resolver, fails for some of them and not for others. A receiver uses one of those too. Any resolver that cannot resolve a name, finds no record, or sees a different answer than this host (split-horizon DNS) is a warning, naming the resolver and — when it sends one — its RFC 8914 extended error (No Reachable Authority: At delegation …). When the zone’s nameservers resolve to this host’s own or private addresses, the warning says so and names the usual causes: a port forward that is missing or disabled for UDP or TCP port 53, an IPv6 firewall rule, or a served zone that is not the delegated one. The check subcommand reports these as[UNREACHABLE](a correct record the Internet cannot read) and lists thePTRverdicts in a reverse DNS group of their own.Resolvers that cannot be reached from this host are dropped silently (a host without IPv6 connectivity loses the IPv6 ones); when none can be reached, a warning is logged once and only the local view is checked.
PUBLIC_RESOLVERS = noneturns this check off.MTA-STS cross-checks. Everything else pepsi-setup verifies about MTA-STS is self-consistent by construction: the
_mta-stsTXT record carries anidderived from the policy, and the policy is derived from the configuration, so comparing them can only succeed. Two things are not derived from the configuration, and underMTA_STS_MODE = enforceeither one being wrong silently stops all inbound mail for the domain. Both are checked on everypepsi-setup run, and reported by the check subcommand and the web console; like thePTRfindings they are warnings and never fail the run.The policy’s ``mx`` set against the live MX records. A policy is a promise that senders may connect only to the hosts it names. A sender resolves the domain’s
MX, finds a host the policy does not permit, and refuses to deliver — at every compliant sender at once, with no symptom on this side beyond mail that never arrives. This is the failure theMTA_STS_MXfallback invites, becauseHOSTNAMEis theEHLOgreeting name and need not be a name anyMXrecord points at. The warning names the hosts on each side and prints the singleMTA_STS_MXline that would make them agree, qualified per domain when the served domains have different MX hosts, along with the alternative of changing theMXrecord instead and the advice to fall back toMTA_STS_MODE = testinguntil they do agree. Also reported here: a domain with noMXrecord, an RFC 7505 null MX on a domain we accept mail for, anMXtarget that is aCNAME(forbidden by RFC 5321 §5.1, and senders may refuse it), a policy entry that matches no liveMXhost (harmless, but it widens what senders will connect to), and aDOMAIN:HOSTentry naming a domain that is not inACCEPTED_DOMAINS— which applies to no policy at all and is what a typo in the option looks like.Whether the policy can be fetched.
pepsi-setuprequestshttps://mta-sts.<domain>/.well-known/mta-sts.txtand compares what comes back with the policy this configuration defines — semantically, so line endings and key order are not differences. Redirects are not followed, because RFC 8461 §3.3 forbids a sender from following them, and a policy served only behind one is a policy no sender can read. A correct TXT record in front of a policy nobody can fetch is not a hypothetical: the server is one unreachable socket, misconfigured front server or missing certificate away from every sender getting a502instead, while every unit staysactiveand nothing is logged — and senders that already cached a policy keep enforcing the old one for up tomax_age, so the failure is not even self-limiting. A5xxis reported with the front-server socket as the thing to check.When the published name cannot be reached from this host — it does not resolve yet, or the network will not let the host reach its own public address — the request is retried against this machine over the loopback interface, keeping the published name in SNI and
Hostso the certificate is still validated against the name a sender would use. That result is labelled as a local probe, because it says what a sender would get oncemta-sts.<domain>resolves here; it is the reason a purely local misconfiguration is still reported on a host whose DNS is not yet published.
The SRS domain can receive its own bounces. When a stage runs pepsi-stage-srs(1), pepsi-setup checks that
[pepsi-srs] SRS_DOMAINhas anMXrecord — or, failing that, anA/AAAArecord, RFC 5321 §5.1’s implicit MX. An SRS address is a return path: the rewrite makes this host the bounce destination for mail it did not write, and the HMAC in the address exists so that pepsi-ingress(1) can decode a returning bounce and relay it to the original sender. A domain with neither record ends that round trip in silence — forwarding works, SPF passes at the next hop, and every bounce is discarded by the MTA that tried to return it, so the original sender is never told their message did not arrive. An RFC 7505 null MX (MX 0 .) is the same answer stated deliberately, and anMXtarget that is aCNAMEis reported for the reason it is everywhere else (RFC 5321 §5.1, RFC 2181 §10.3). The warning prints the record to publish, naming a host the accepted domains already route mail to, and offers the two alternatives: pointSRS_DOMAINat a domain that already receives mail here, or drop the stage if nothing is forwarded off-site. Like the checks above it is a warning and never fails the run.The check exists because the omission is invisible from every other angle. The SRS domain is a sending identity, so it joins the set that gets DKIM keys and SPF and DMARC advice; pepsi-setup suggests no
MXrecord for any domain, that being the operator’s own routing decision. An operator who publishes everything they are told to publish therefore ends up with two of the three records the domain needs, and nothing anywhere says so.Forward-confirmation queries authoritative DNS only and deliberately ignores the local
/etc/hostsfile — a mail host commonly lists its own name there against127.0.1.1or a NAT-internal address, which would otherwise make a perfectly forward-confirmed name look unconfirmed. These findings are deliverability warnings, not errors: they never fail the run.Verifies the payment backend (optional). When a
[pepsi-payments]section configures a GNU Taler merchant backend, pepsi-setup checks it over HTTP:GET /configmust identify ataler-merchantbackend (so the URL is correct) andGET /private/orders?limit=1must return200(so theMERCHANT_ACCESS_TOKENis correct). It then ensures apepsi-resumewebhook exists thatPOSTs to pepsi-httpd’s/resumeendpoint whenever an order is paid — creating it if absent, or, if apepsi-resumewebhook already exists, logging a warning when its definition differs from the expected one (it is never silently overwritten). The webhook targets the shortest configured[pepsi-httpd-cert-*]SNI host over HTTPS and authenticates with[pepsi-httpd] RESUME_AUTHORIZATION_TOKEN. When no merchant backend is configured this step is skipped entirely.Reminds about credentials that expire out of band. When any smarthost MTA uses
AUTH = oauth, pepsi-setup validates the corresponding[pepsi-helper-token-refresh-<name>]secret sections it can read (it runs asroot) and prints a reminder that those access tokens must be kept current — for example by enabling pepsi-helper-token-refresh(1), which is not part ofpepsi.target. When one usesAUTH = gssapiit likewise reminds that the Kerberos credential cache has to be renewed out of band (a keytab plusk5startor cron) and be readable by the dispatcher’s worker, sanity-checking any cache path it can see. Both reminders go to the log (standard error), after the zone output, so they can never corrupt a redirected zone file.
All behaviour is driven by an INI-style configuration file (see pepsi.conf(5)). The same configuration file is shared with the other Pepsi tools.
85.1.39.1.4. Privileges and ownership¶
pepsi-setup may be run either directly as the database owner (for example
su pepsi-owner -c 'pepsi-setup … run') or as root. When it is run as
root it manages two distinct identities itself, so the resulting objects are
owned by the right accounts:
Database objects are created as the
pepsi-ownerrole. Before any database work (schema install, role provisioning, settings validation, payment-backend check) pepsi-setup assumes the effective identity of thepepsi-owneruser; PostgreSQL peer authentication then connects as the same-named role, so thepepsischema, its tables, sequences and functions are owned bypepsi-owner. Thepepsi-owneruser/role and the database itself must already exist (the Debian package creates them; otherwise create them by hand). Only the effective uid changes, so root is regained afterwards.Key material is handed to the
pepsiaccount. The per-domain DKIM keys are generated back as root (mode0600) and thenchowned to thepepsiuser — the dispatcher account that signs outbound mail — so the signing stages can read them. When thepepsiuser does not exist this is a logged no-op.
When pepsi-setup is not run as root, neither switch happens: it operates
entirely as the invoking user, which must therefore be the database owner and be
able to read/write KEY_DIR.
Note
ARC sealing (performed by pepsi-stage-arc(1) on inbound mail) reuses the
DKIM key of the [pepsi] ARC_DOMAIN, so no separate ARC key or DNS record is
generated:
ARC verifiers fetch the seal’s public key from the same DKIM record. When
ARC_DOMAIN is set, pepsi-setup simply includes it among the key/DNS
domains.
85.1.39.1.5. Wizard¶
For a first deployment, –wizard replaces hand-editing with a short interactive interview and writes a complete, already-validated configuration file.
The interview is presented as a sequence of numbered steps (each shown as a
Step X/Y banner with a one-line description, in colour on a capable terminal
— set the NO_COLOR environment variable to disable it), grouping related
questions: Server identity, Existing mail system, Local delivery,
Submission authentication, Trust loopback, Forwarded mail,
Transport security, Content filtering, Anti-spam paywall, Account
self-service and End-to-end cryptography (plus Expert options under
–expert). Which steps appear (and therefore Y) follows from the chosen
direction and whether a local Dovecot is detected. The manual’s The Wizard
chapter draws the pipeline each direction produces and lists what every
question decides. When stdin is a real
terminal, prompts are line-edited with the usual GNU-readline key bindings
(Home/End, Ctrl-Left/Ctrl-Right to skip words, Ctrl-A/
Ctrl-E/Ctrl-K/Ctrl-U/Ctrl-W, and Up/Down to recall
earlier answers); a piped (non-terminal) stdin — as used by the test suite and
scripts — falls back to plain line reads. Each answer is validated the moment
it is entered (hostnames and domains must be fully-qualified, the postmaster a
valid address, sending IPs real IP literals, ports numeric, the payment hold a
h/m/s duration, and so on), so a mistake is reported at its own
prompt and corrected on the spot rather than only at the final whole-config
validation.
It first asks the direction the host serves — inbound (receive mail for
our domains from the Internet, delivering it locally and/or relaying it
onward), outbound (send our own users’ submissions directly to the
Internet) or both (the default; split on state.local_origin with a
routing stage) — which determines the SMTP
listeners (port 25 for inbound, 465/587 for outbound) and the relay stages that
appear. It then asks for the hostname, accepted domain(s), postmaster address,
public sending IP(s) and the database connection string, proposing sensible
defaults (the parent of the hostname as the domain). The hostname default is
taken from reverse DNS: the wizard detects this host’s public addresses (the
same detection described for the sending IPs below), looks up their PTR
records, and offers the first name that forward-confirms back to the address
and does not look auto-generated. Only if there is no such name does it fall back
to /etc/hostname. This is deliberate — a receiving mail server compares the
name announced in EHLO against the PTR of the address the session comes
from and rejects when they disagree (HELO host does not match rDNS), while
/etc/hostname is a purely local label no receiver ever sees. On a host that
answers to several names the two routinely differ, and reverse DNS lives in the
IP owner’s zone, so it is normally the half that cannot be changed. When the
offered default (a previous run’s answer, or /etc/hostname) contradicts the
PTR, the wizard says so and names both before asking. The
public sending IP(s) default is auto-detected and offered for
confirmation: the hostname’s published A/AAAA records unioned with the
host’s own globally-routable interface addresses, and — only when that finds no
public IPv4 (a NAT host with a private IPv4) — the WAN IPv4 reported by the local
UPnP gateway via upnpc (miniupnpc), if installed. Only public addresses
are suggested: a private/loopback/link-local address is never pre-filled (behind
NAT the detected value is the public egress IP, not the LAN address). The
operator confirms or edits it before anything is written.
On the inbound path the next question is whether an existing mail system —
Microsoft Exchange or Microsoft 365 — sits behind this host. Answering yes makes
Pepsi a transparent gateway: it becomes the MX and hands everything to the
tenant endpoint, so there are no local mailboxes and the local-delivery
questions below are skipped. It then asks for that endpoint’s host and SMTP port
(warning that it must be the tenant-specific endpoint, not the domain’s public
MX, which is this host and would loop) and for the IP address family to reach it
over (any/ipv4/ipv6 — Exchange Online refuses mail from an IPv6
address with no reverse DNS, so ipv4 is the escape hatch when IPv6 reverse
DNS is not delegated).
Otherwise, on the inbound path it asks which
local-delivery method(s) to use — Maildir (pepsi-stage-relay-to-maildir)
and/or LMTP to an MDA (pepsi-stage-relay-to-lmtp) — and, when Maildir is
enabled, whether to honour per-user ~/.forward files
(pepsi-stage-dot-forward; the generated section forwards to addresses only,
with ALLOW_PIPE / ALLOW_FILE left off).
Still on the inbound path, it asks whether this host should answer mail for
recipients who are away (pepsi-stage-vacation, default no). Answering yes
only puts the stage in the pipeline — after alias expansion and after the spam
gates, so a notice is never sent on behalf of an alias or in reply to mail the
gates would have refused. Nobody is answered until leave dates exist, and
those are ordinary per-address configuration rather than a wizard answer: a user
can set their own by e-mail, an operator with pepsi-settings set <address>
vacation VACATION_RANGES …, and a holiday everybody shares with
pepsi-config set --scope domain:<domain> stage-vacation VACATION_RANGES ….
The generated section therefore leaves VACATION_RANGES unset (with the syntax
in a comment) and points RESPONSE_STAGE at the shared signing tail so each
notice is signed and relayed. On a host with no local delivery — a pure
forwarder, or an Exchange front — it also sets VACATION_TAG = none: tagging
the subject would otherwise rewrite a header covered by the author’s DKIM
signature and by this host’s own ARC seal, which the next hop would see fail. See
pepsi-stage-vacation(1).
It then asks for the upstream smarthost (optional when local delivery or an Exchange system handles the served domains, required otherwise).
Two questions then decide how mail leaves this host with a signature of its
own. The first concerns programs running on this host — cron,
mail(1), logwatch, git send-email or a web application — which are
otherwise refused with 550 5.7.1 Relaying denied for any recipient outside a
served domain.
The wizard does not ask whether to serve the UNIX-domain submission socket
that /usr/sbin/sendmail uses. pepsi-ingress always serves it, deriving it
from [pepsi-sendmail] SOCKET, so no listener section is written and there is
nothing to answer wrongly. The kernel tells pepsi-ingress which account
opened the socket, so a caller can only ever send as itself: the login is run
through USERNAME_MAP exactly as a SASL username would be. See
pepsi-sendmail(1), and SOCKET = none to switch it off.
What it does ask, on a host serving both directions only (the answer
becomes MYNETWORKS on the port-25 MX listener, which only an inbound host
has, and is only worth asking of a host that also submits), as a yes/no
question defaulting to no, is whether to additionally trust loopback:
TRUST_LOOPBACKAdd
MYNETWORKS = 127.0.0.0/8 ::1/128to the port-25 listener (merged with any networks set through the expert options, never duplicated). This trusts a network position rather than a user, so any local process — including a compromised web application — can send as anyone; and it is also what lets a message this host relays to itself (a served domain whose public MX is this host) re-enter as a fresh submission and loop. Say yes only for software that insists on speaking SMTP to port 25 and cannot be pointed at the socket.
Either way the resulting messages count as locally originated
(state.local_origin) and are therefore DKIM-signed by the outbound path.
On the inbound path it asks whether to DKIM-sign the mail this host forwards
(defaulting to yes): an alias expanding off-site, a ~/.forward pointing at
another provider, or the whole pure-relay path. Those messages leave down the
inbound tail, which the outbound [stage-dkim-sign] never touches, so without
this they arrive at the next hop with our SRS envelope but dkim=none from us.
Answering yes emits a second signing stage, [stage-dkim-sign-relay], between
the SRS rewrite and the destination, with SIGNING_DOMAIN pinned to the
primary served domain. That pin is deliberate: signing is fail-closed, and the
author’s domain belongs to somebody else — deriving the signing domain from a
foreign From: would fail every forwarded message for want of a key.
The host’s own identity — the ARC sealing domain
([pepsi] ARC_DOMAIN), every relay/bounce SERVER_NAME and the fallback
listener certificate — is the configured hostname throughout, distinct from the
accepted recipient domains (which scope SRS, MTA-STS and the TLS-report
addresses).
The way those listeners bind their ports depends on the host. When the system
is managed by systemd (detected by the presence of /run/systemd/system),
the wizard writes socket-activated listeners (SERVE = systemd with a
FD_INDEX per listener) so the privileged ports are bound by .socket
units and inherited by the unprivileged service; otherwise it falls back to
direct binds (SERVE = tcp with BIND_TO / PORT). The ingress
listeners always take FD_INDEX 0 for port 25, 1 for 465 and 2 for 587,
matching the shipped pepsi-ingress.socket, which lists all three whichever
direction the host serves. With socket activation you must install the
matching .socket units (a pepsi-ingress.socket listing
ListenStream=25, 465 and 587 in that order, and a
pepsi-httpd.socket for port 443); the wizard prints a reminder after writing.
Two transport-security questions follow. On the inbound path it asks whether to
serve an MTA-STS policy (default yes): when enabled it sets
MTA_STS_MODE = enforce and adds a [pepsi-httpd-cert-*] section for each
accepted domain’s mta-sts.<domain> policy host (served over HTTPS by
pepsi-httpd), whose certificate pepsi-setup provisions via certbot like
any other — you must point each mta-sts.<domain> name’s DNS at the server for
the policy to be reachable. The hosts the policy permits are not asked for: they
are read from the served domains’ live MX records and written as [pepsi]
MTA_STS_MX (left out when no MX record exists yet, so the policy falls back
to HOSTNAME; see MTA-STS cross-checks above). It then asks whether to send SMTP TLS Reports
(TLSRPT, RFC 8460; default yes), which emits the [pepsi-tlsrpt] section.
The same step closes with the one question that is not about this deployment at
all: whether to share anonymous feature-usage telemetry with the Pepsi
project ([pepsi] SHARE_TELEMETRY, default no). It is opt-in, so the
prompt explains what would be sent before asking — a random 256-bit
SYSTEM_ID and counts of which Pepsi features this host has exercised, never
addresses, message data, host names or IP addresses — and pressing Enter through
the whole interview therefore shares nothing. Saying no disables the entire
path: no identifier is generated, setup never starts the daemon, and one the
operator started is sent dormant. See pepsi-telemetry-client(1).
The pepsi-httpd sections are always written, whatever else is declined:
HTTPS on port 443 with the host certificate the SMTP listeners use (serving the
MTA-STS policy when that is on), and the administrative console on
/run/pepsi/admin.sock for the pepsi-admin group. pepsi.target
starts the server and its socket on every host, so a configuration without a
listener would leave it failing and restarting.
A short feature menu follows, asked only where it applies to the chosen
direction: whether to block mail by detected language (and, if not, whether
to still localize bounce messages, which enables language detection). When
detection is enabled it first prints the full list of supported ISO 639-1 codes
and asks which languages the detector should recognise, accepting * (the
default) for every language the detector supports and rejecting any
unsupported code on the spot (rather than only at the final validation). Only a
detected language can then be allowed or blocked, so when blocking is on it next
asks for a single combined language policy in a +allow/-block syntax
— e.g. +en +fr -ja -zh allows English and French while blocking Japanese and
Chinese, and a single -* (or +*) wildcard covers every other detected
language (so +en +fr -* allows only English and French). The literal
none may be listed to decide the fate of undetected mail. This one answer is
split into the stage’s WHITELIST and BLACKLIST options. It then asks
whether to ask unknown senders to confirm by reply (SECRETARY,
pepsi-stage-secretary(1), default no), and about the
GNU Taler anti-spam paywall (which also enables the correspondent whitelist
so known contacts skip the payment request). When the paywall is enabled the
wizard asks for the merchant backend URL first and immediately verifies it
over HTTP (GET /config must identify a taler-merchant backend), reading
back the currencies the backend supports — the price must be quoted in one of
them, so the URL has to be known before the price is asked. It then asks for the
access credential: either a ready-made access token (recognised by its
secret-token: prefix, and verified against the backend) or the instance
password, which the wizard exchanges for a scoped access token via
POST /private/token (requesting the all scope so the token can also
manage the pepsi-resume webhook). Finally it asks the price per message
as a space-separated list of Taler amounts (e.g. EUR:1 CHF:1 KUDOS:10),
validated against the backend’s currencies; each amount becomes one choice in
the generated v1 order ORDER_CHOICES, letting the payer pick a currency.
When both the paywall and confirm-to-send are off, and the host also has an
outbound path, the wizard instead asks whether to merely learn a
correspondent whitelist from outgoing mail.
On the outbound path a self-service step then asks up to two independent yes/no questions, both defaulting to no: whether account owners may edit their own per-address settings by e-mail (pepsi-stage-edit-settings(1)) — asked only on a host serving both directions, because every stage an account owner may edit is an inbound one — and whether they may operate their own GNU Taler wallet by e-mail — balance, top-up and peer-to-peer payments, served by the same pepsi-stage-auto-pay(1) that pays inbound payment demands.
The last step, asked whichever direction was chosen, is end-to-end
cryptography (default no): whether this gateway should encrypt and decrypt
mail itself (OpenPGP and S/MIME), so users need no client plugin. Saying yes puts
pepsi-stage-encrypt(1) and pepsi-stage-decrypt(1) in the pipeline;
nothing is encrypted until a recipient’s key is known, and keys come from
discovery (pepsi-keydisc(1)), from pepsi-keys(1), or from the mail
correspondents send us. On the inbound path it then asks whether to learn
correspondents’ keys from that mail (Autocrypt, default yes), which adds
pepsi-stage-autocrypt-learn(1), and whether to re-encrypt what the gateway
decrypted to the user’s own mail-client key before filing it (REENCRYPT,
default yes), which adds pepsi-stage-reencrypt(1) in front of local
delivery. On the outbound path it asks whether to
enable pEp-style automatic encryption (PEP, default yes): every
sender at a served domain gets an OpenPGP key on their first message, advertised
in an Autocrypt: header, and mail is signed inside the encryption. It
states the cost before asking – eager key creation puts a wrapped private key
for everybody who sends mail into the database, and their public key into the
Web Key Directory, whether or not they ever use encryption – and writes the
answer explicitly, ENABLE_PEP = yes or ENABLE_PEP = no, into
[stage-encrypt] in pepsi.conf, where it takes effect (no config.d
file sets it). When accepted it asks whether to also
attach the sender’s key as a file (ATTACH_KEYS_AS_FILES, default no),
written only when chosen. The encrypt stage is always given RESPONSE_STAGE =
dkim-sign, so the key-registered notices and the replies to the e-mail key
commands are signed and relayed like any other outgoing mail. The browser setup
interview asks the same questions. The key store, key discovery and the
key-encryption key are provisioned by the rest of setup regardless of the
answer.
SRS is wired automatically on every inbound path, whatever the relay
destination, with a generated secret; only its SRS_DOMAIN is asked (on the
same step as the forward-signing question, defaulting to the primary served
domain).
85.1.39.1.5.1. Mail filters (milters) found on this host¶
On a host that receives mail, the wizard scans it for milter daemons and
offers each one it finds as a pepsi-stage-milter stage (see
pepsi-stage-milter(1)). It never installs one, and never starts or
confines the daemon: as under sendmail and Postfix, the filter keeps its own
package, unit and user account, and Pepsi is only a client. The scan is skipped
on an outbound-only host, where there is no inbound path to place a filter on,
and every filter it finds is placed on the inbound path. A filter for
submitted mail is configured by hand, or — on a host that also receives —
carried over from an imported non_smtpd_milters.
Seven filters are recognised: milter-greylist, milter-regex,
clamav-milter, spamass-milter, rspamd (through its rspamd_proxy
milter worker), mimedefang and amavisd-milter.
Each is looked for in three stages, and a filter is offered only when all three succeed:
Installed — its binary is on
$PATHor in the usualsbindirectories, or a systemd unit file for it exists.Listening somewhere — a socket is read from the filter’s own configuration file (
clamav-milter.conf’sMilterSocket,greylist.conf’ssocket, an/etc/defaultfragment’sSOCKET=or-pflag, Rspamd’sbind_socket), then from its unit’sExecStart, then from the paths its distribution ships.Answering — setup completes a real milter option negotiation with each candidate socket and takes the first that succeeds.
The handshake is what makes the offer trustworthy: an installed package proves
nothing about a stopped daemon, and a wrong guess in the path lists costs
nothing, because a socket that does not answer is simply never offered. It also
settles what the filter is allowed to do. ALLOW_ACTIONS must grant every
action a filter asks for or pepsi-stage-milter refuses the message by
design, and the filter states that set during negotiation — so setup offers
everything purely to hear the answer, and writes out precisely what was asked
and nothing more. A filter that only inspects is granted none; MIMEDefang
is granted the envelope actions exactly when the site’s own Perl policy uses
them.
Each accepted filter becomes one stage, placed by what it does rather than
by the order it was found: policy and greylisting first, then virus scanning,
then spam scoring, then the general-purpose frameworks — and all of them after
[stage-decrypt] (so a filter sees plaintext) and before the whitelist,
paywall and Autocrypt stages (so those can branch on whatever a filter tagged).
Filters carried over from an MTA import have no known purpose and therefore run
after all of these, keeping the relative order that MTA ran them in.
Rejected mail is dropped rather than bounced. Pepsi runs filters
post-queue, so a REJECT applies to a message pepsi-ingress has already
answered 250 to: it cannot refuse the SMTP session the way the filter did
under an MTA, and bouncing would send backscatter to whatever address forged
spam named. Each detected filter’s REJECT_STAGE therefore points at a
generated [stage-discard-rejected] (a pepsi-stage-discard with
DISPOSITION = failure, BOUNCE = no); the verdict is still recorded in
state.milter and in the log. Setting REJECT_STAGE = bounce restores
notification, and the generated section says so. A filter that came from an MTA
import keeps bounce, since telling the sender is the behaviour being
carried over.
One recognised filter is offered but not proposed: milter-greylist
defaults to no. Greylisting works by refusing delivery inside the session and
betting that a real MTA retries where a spam engine does not — and post-queue
there is no session to refuse and Pepsi itself is what retries, so the triplet
ages out and the message is delivered whatever sent it. The spam-blocking effect
is zero and only the delay on each new correspondent remains. It is still
offered, because a site may run milter-greylist for other policy its ruleset
carries.
Filters whose function Pepsi already performs are not offered:
opendkim, openarc, opendmarc, SPF policy daemons and postsrsd.
Running one alongside Pepsi means two components signing or judging the same
message. They are not silently skipped — when one is found, the scan says so and
names the Pepsi feature that replaces it, so that a scan which found nothing to
offer is distinguishable from a scan that did not run. Spam classification is
deliberately not in this category: Pepsi has its own opinions about spam, but
they are different opinions from a Bayesian scorer’s, and an operator may
reasonably want both.
Each answer is remembered under its own MILTER_* key (MILTER_CLAMAV,
MILTER_RSPAMD, …) in [pepsi-wizard]. Only filters this run actually
found are written there; a re-run scans again.
The generated configuration is validated through exactly the same checks as
run before it is written, so the wizard never produces a file that would
later fail to load. If validation does fail, the wizard prints the offending
error and re-runs the interview with your previous answers pre-filled as the
defaults, so you can correct the broken option(s) without re-entering
everything. The interview answers are recorded in a [pepsi-wizard]
section at the end of the file (secrets — the smarthost password, merchant
access token and the generated SRS key — are not stored there); re-running
–wizard imports them as the new defaults.
Because the main configuration is written world-readable (mode 0644, so the
unprivileged stage accounts can read it), the wizard does not leave secrets
in it. Every secret it would otherwise write inline — the SRS SECRET, the
smarthost/LMTP PASSWORDs, the merchant MERCHANT_ACCESS_TOKEN and the
pepsi-httpd RESUME_AUTHORIZATION_TOKEN — is moved into a restricted
secrets.d/*.secret fragment next to the configuration and referenced from it
with an @inline-secret@ directive (see pepsi.conf(5)). The generated
ones (the SRS key, the /resume token) are read back from the existing
fragment and reused, so re-running the wizard never silently rotates them (which
would invalidate SRS senders already in flight or the deployed payment webhook).
85.1.39.1.5.2. Secret prompts¶
The secrets you type — the smarthost and LMTP passwords, the merchant access
token — are never echoed and never written into [pepsi-wizard]. On a re-run
they are read back from their secrets.d fragment, so the prompt shows
[***] and Enter keeps the stored secret:
Smarthost password [***]:
Three answers are possible:
- Enter
Keep what is stored. When the fragment exists but this process may not read it (it belongs to the service account and the wizard is running unprivileged), the fragment is left completely untouched and the configuration keeps pointing at it — the secret is preserved without the wizard ever seeing it.
- a value
Replace the stored secret with what you typed.
- Enter twice
Leave the secret unset for now. This is offered only when nothing is stored and only on a terminal; the wizard then writes the configuration without that option, prints the section, option and file to add it to, and leaves a commented line in the fragment naming it. The services will not work until you fill it in and run
pepsi-setup run.
For a smarthost that authenticates, the wizard then offers to check the credentials by opening a session to the relay and authenticating — exactly what a delivery does, minus the message — so a mistyped password surfaces while you are still there to correct it rather than as mail that quietly stops flowing:
Check these credentials against smtp.relay.example:587 now [Y/n]:
If the relay refuses them the wizard offers to take the password again; if it cannot be reached at all (firewall, DNS, the host being down) it says so and does not push you into retyping a password that may be perfectly good. The check is skipped entirely when stdin is not a terminal: an unattended run must not depend on the network being up. Each directive is written after the last option of the section it feeds, because a directive ends its section for the parser (see pepsi.conf(5)). The fragments are secured (mode and ownership) as soon as they are written and again by run; see the run command below. The wizard then re-loads the file it has just written and validates that, so a configuration it cannot itself parse is never left behind. After writing, the wizard offers to run the full setup (run) immediately.
If the target file already exists and contains a [pepsi-wizard] section, its
answers are imported as defaults. If it exists without one, the wizard warns
that it cannot import previous answers and asks for confirmation before
overwriting; –force overwrites without prompting.
Note
The anti-spam paywall requires its message templates (e.g.
payment-request.en.body) to be installed under [pepsi] TEMPLATE_DIR
(default ${DATADIR}/templates, i.e. /usr/share/pepsi/templates for a
--prefix=/usr installation); make install places them there.
Because the wizard validates against the live environment, enabling the
paywall before the templates are installed reports a clear error rather than
writing a configuration that would fail at run time.
85.1.39.1.5.3. Answering without a terminal¶
–answers FILE runs the whole interview from a JSON object of question
identifier to value, asking nothing. It is not a second implementation of the
wizard: the same interview() runs, with every prompt short-circuited to the
supplied answer (or, where there is none, to the wizard’s own default, exactly
as pressing Enter would). The branch structure, the per-field validation and the
rendering are therefore the interactive ones.
The identifiers are what pepsi-setup questions prints, and they are the same
keys the generated [pepsi-wizard] section round-trips — so a configuration a
previous run wrote is itself a valid answer file once that section is turned into
JSON. The expert options (–expert, and whatever an MTA migration found)
share that namespace and are accepted here too, validated by the same check the
--expert prompt applies; the browser interview deliberately refuses them, since
an option nothing asks about is not one a web form should set. An unknown
identifier is an error naming the key rather than a silently dropped line, because
an answer that is quietly ignored looks exactly like one that was accepted and had
no effect.
Secrets may appear in the file (SMARTHOST_PASSWORD, LMTP_PASSWORD,
MERCHANT_ACCESS_TOKEN); they are moved into secrets.d fragments like any
other, so the file itself should be treated as a credential and deleted
afterwards. A secret already stored in a fragment is carried forward when the
answer file does not mention it, which is what makes a scripted re-run
non-destructive.
Example:
cat > answers.json <<'EOF'
{
"DIRECTION": "both",
"HOSTNAME": "mx.example.com",
"DOMAINS": "example.com",
"POSTMASTER": "postmaster@example.com",
"PUBLIC_IPS": "203.0.113.7",
"LOCAL_METHODS": "maildir",
"MTA_STS": "yes"
}
EOF
pepsi-setup --wizard --answers answers.json -c /etc/pepsi/pepsi.conf
85.1.39.1.6. Migrating from another MTA¶
Nobody installs a mail server on an empty machine. When –wizard finds no configuration at the target path, it looks for the mail server the host runs today — Postfix, Exim, Sendmail, qmail or Stalwart — and offers to import its settings as the interview’s answer defaults. If several are configured it presents a numbered menu; –import MTA picks one without asking and –no-import skips the search entirely. Auto-detection is skipped when stdin is not a terminal, so a scripted –wizard run keeps producing the configuration its answers describe rather than one shaped by whatever was left over on the machine.
Nothing is imported silently. Every imported value becomes the [default] on
its question, which the operator confirms or corrects, and everything that could
not be carried over is collected into a migration report written next to
the configuration as import-report.txt (also printed as a one-line summary
before the interview starts). The report groups its findings into four
categories:
FAILEDA file could not be read or parsed; nothing was imported from it.
UNSUPPORTEDUnderstood, but Pepsi has no equivalent — a
header_checkstable, an alias delivering to a command. The entry names what Pepsi offers instead, where something does.APPROXIMATEDImported with different semantics; mbox delivery becoming Maildir, a
maximal_queue_lifetimerounded to whole hours, a loopback network dropped fromMYNETWORKS.UNKNOWNA directive the importer does not recognise, and therefore did not migrate. Reported so an unrecognised setting is visible rather than invisible.
An import can never fail the run: an unreadable file, a corrupt line or a directive from a fork nobody has heard of produces a warning, and the rest of the configuration is still imported.
85.1.39.1.6.1. Routing tables¶
/etc/aliases, /etc/postfix/virtual, Sendmail’s virtusertable,
qmail’s .qmail-* files and their equivalents are converted into a Pepsi
alias map (see pepsi-stage-aliases(1)) written next to the configuration as
aliases, and a submission-identity table (Postfix’s
smtpd_sender_login_maps and friends) into username.map (see
USERNAME_MAP in pepsi-ingress(1)). An existing file is never
overwritten: the converted table lands at aliases.imported instead, for the
operator to merge.
Pepsi alias keys carry a domain (local@domain, @domain or a *
glob), while /etc/aliases keys are bare local parts, so the wizard asks how
to qualify them (–alias-style for the import command):
per-domainOne line per alias and accepted domain — exact, and the default.
wildcardOne line per alias with a domain glob (
postmaster@*) — compact, but it also matches domains added later.primaryQualify with the first accepted domain only; the others are reported as not aliased.
Entries Pepsi cannot express — a delivery to a command (|/usr/bin/…), to a
file, or an unreadable :include: — are written into the converted map as
inert # UNCONVERTED comments next to a report entry explaining why, so
nothing disappears without a trace. The generated map is checked with the same
strict syntax check run applies to a hand-written one.
85.1.39.1.6.2. Per-server coverage¶
- Postfix
/etc/postfix/main.cf(with$parameterinterpolation and continuation lines) andmaster.cf. Identity comes frommyhostname/mydomain, the accepted domains frommydestination,virtual_alias_domains,virtual_mailbox_domainsandrelay_domains; the smarthost fromrelayhostwith its transport fromsmtp_tls_security_level/smtp_tls_wrappermodeand its credentials fromsmtp_sasl_password_maps. Local delivery is read fromhome_mailbox,mailbox_transport/virtual_transport(anlmtp:unix:target becomes Pepsi’s LMTP stage) andmail_spool_directory; submission SASL fromsmtpd_sasl_type = dovecotandsmtpd_sasl_path; the enabled services inmaster.cfdecide the direction.alias_maps/alias_databaseandvirtual_alias_mapsbecome the alias map (virtual entries win, as they do in Postfix), andsmtpd_sender_login_mapsbecomesusername.map, inverted to Pepsi’s login-keyed form.message_size_limit,mynetworks,recipient_delimiter,maximal_queue_lifetime,smtp_helo_nameand thesmtpd_tls_*certificate paths arrive as expert options.Reported rather than migrated:
header_checks/body_checks,transport_maps, canonical rewriting,smtpd_*_restrictions,postscreen_*,content_filter, virtual-mailbox delivery, per-service-ooverrides inmaster.cf, and map types nothing can read as text (regexp:,pcre:,mysql:,ldap:, …). Roughly ninety parameters that describe Postfix’s own installation or tuning are ignored silently; everything else is listed as unrecognised.- Exim
Debian’s answer file
/etc/exim4/update-exim4.conf.confis the richest and most reliable source and takes precedence:dc_eximconfig_configtypegives the direction and whether there is a smarthost,dc_other_hostnamesthe domains,dc_smarthostthe relay,dc_relay_netsthe trusted networks anddc_localdeliverythe mailbox format. The main section ofexim4.conf.template/exim.conf/conf.d/main/*(everything before the firstbegin) addsprimary_hostname,qualify_domain,domainlist local_domains,hostlist relay_from_hosts,message_size_limit,smtp_accept_maxand thetls_certificatepair. Exim’s list syntax is handled properly — doubled separators,<;overrides,.include,+namedreferences, and.ifdefevaluated against the macros actually defined./etc/aliasesbecomes the alias map,/etc/email-addressesthe submission-identity map, and/etc/exim4/passwd.clientthe smarthost credentials.Exim’s routers, transports, ACLs, rewrite rules, authenticators and retry rules are a programming language, and a half-parse of one produces a plausible but wrong migration. They are therefore not interpreted at all: each
beginblock is reported once, naming what replaces it in Pepsi (the stage graph for routers and transports, listenerMYNETWORKS/SASL plus the whitelist and anti-spam stages for ACLs, SRS and the alias map for rewriting). Two consequences: because the retry ladder lives inbegin retry,MAX_LIFETIMEis never guessed, and because the transport holds the TLS and AUTH policy, the smarthost’s transport security is assumed rather than read. Atls_certificatecontaining a$expansion is a computation, not a path, and is reported instead of imported.- Sendmail
/etc/mail/sendmail.mc— the m4 source an administrator actually edits — is preferred;sendmail.cfis used when no.mcexists, and then only its reliably-readable lines (Djthe canonical name,DSthe smart host,Cw/Fwthe local host names,DZthe version and theOoptions). The generated rulesets are never interpreted, and the report says so. From the.mc:SMART_HOST(with its mailer prefix, port and brackets),confDOMAIN_NAME,confMAX_MESSAGE_SIZE,confTO_QUEUERETURN,confMAX_DAEMON_CHILDREN,confSERVER_CERT/confSERVER_KEY,ALIAS_FILE,DAEMON_OPTIONS(which ports were served, hence the direction) and theFEATUREs that matter —use_cw_file,virtusertable,authinfo,local_lmtp,plussed_users,local_procmail,nullclient. The tableslocal-host-names,virtusertable,aliasesandauthinfo(the smarthost credentials, with the mechanism from itsM:field) are read;postmasteris resolved through the alias chain to a real address.Reported rather than migrated:
MASQUERADE_ASand friends (Pepsi rewrites senders with SRS instead),access_db,mailertable,domaintable,genericstable,LOCAL_RULE_*/LOCAL_CONFIG/HACK, DNSBL and greet-pause features,smrsh/procmail local mailers, andvirtusertableright-hand sides Pepsi cannot express (error:,%1).- qmail
The control directory (
/var/qmail/control,/etc/qmailor/usr/local/etc/qmail) is enumerated, not probed by name, so every control file is either consumed, silently ignored as irrelevant, or reported — including ones from a fork this importer has never heard of.megives the hostname;locals,rcpthosts,morercpthostsandvirtualdomainsthe domains; the:relay:portcatch-all insmtproutesthe smarthost (with credentials when the AUTH patches’ extended form is used);defaultdelivery(or theqmail-startargument inrc) the mailbox format;databytes,queuelifetime,helohostand aservercert.pemthe expert options.The alias table is the
.qmail-*files in qmail’s alias directory:.qmail-foo-barbecomesfoo-bar,:decodes back to.,.qmail-defaultbecomes a catch-all and.qmail-foo-defaulta glob. Their contents pass through as targets, so a|command, a file path or a relative./Maildir/is explained in the report rather than mangled into a nonsense address. Per-user~/.qmailfiles are deliberately not walked (expensive and privacy-sensitive;pepsi-stage-dot-forwardis the runtime equivalent), and no.cdbis ever opened — the textusers/assignis read instead.badmailfrom/badrcptto,spfbehavior,percenthack,qmqpserversandtlsclientsare reported with their Pepsi counterparts.- Stalwart
config.tomlunder/opt/stalwart-mail/etc,/opt/stalwart/etc,/etc/stalwartor/etc/stalwart-mail(a directory of.tomlfragments is read too, and!includedirectives are followed).server.hostnameand theserver.listener.*ports give the identity and direction,queue.outbound.next-hop/queue.strategy.routeand theremote.*table the smarthost (aprotocol = "lmtp"hop becomes local LMTP delivery instead),certificate.*the TLS paths,session.data.limits.sizeand the queue expiry the expert options. Amemory-typedirectory(or aprincipalsarray) yields accounts: each extra address becomes an alias to the account’s primary one, and the logins becomeusername.map. Secrets written as%{file:…}%are resolved;%{env:…}%cannot be and is reported.Stalwart’s own expressions (
[{if = …}, {else = …}]) are never evaluated — only a plain value is honoured, anything else is reported rather than guessed at. The important case to know about: recent Stalwart versions keep the operational configuration in their data store, not in the TOML. When the file looks like a bootstrap stub, the report says so prominently, names the store, and tells you to export the configuration from Stalwart’s admin interface — there is nothing on disk for Pepsi to read. IMAP, JMAP, POP3, ManageSieve, Sieve, spam-filter and reporting settings are reported as unsupported with a pointer to the Pepsi (or Dovecot) equivalent.
85.1.39.1.6.3. Credentials¶
Smarthost relay credentials are read from the source MTA’s own password file
(Postfix’s smtp_sasl_password_maps, Exim’s passwd.client, Sendmail’s
authinfo, Stalwart’s TOML) so the operator does not have to recover a
password they may no longer have. The report always names the file a secret was
read from. Imported passwords follow the same path as typed ones: the wizard
moves them out of the world-readable configuration into secrets.d/*.secret
fragments referenced with @inline-secret@.
85.1.39.1.6.4. What is not migrated¶
Mail already queued in the old server is not migrated — let the queue drain, or flush it, before switching the MX over. DKIM keys are not migrated either; run generates fresh ones and prints the DNS records to publish. If the old MTA is still listening on port 25 when the wizard runs, the report says so: pepsi-ingress cannot bind the port until it is stopped and disabled.
85.1.39.1.7. Expert options¶
Between the questions the interview asks and the full option set of pepsi.conf(5) there is a tier of ordinary Pepsi options nobody should have to answer to get a working server: the message size limit, the recipient delimiter, trusted networks, the queue lifetime, the DKIM selector.
–expert SPEC makes the wizard ask about them, in a final interview step. SPEC is a comma- or space-separated list of group words and/or option names:
highThe options an administrator plausibly tunes:
MAX_MESSAGE_SIZE,MYNETWORKS,RECIPIENT_DELIMITER,MAX_LIFETIME,DMARC_ENFORCEandMAILBOX_QUOTA(deliberately in this group rather thaninsane: a site that delivers locally will want a default quota and should not have to know the option’s name).insaneEverything in the registry, adding
HELO_NAME,TLS_CERT,TLS_KEY,DNS_TIMEOUT,MAX_CONNECTIONS,MAX_OPEN_SOCKETS,DKIM_SELECTOR,KEY_DIR,MAILBOX_OVER_QUOTAandCRYPTO_ALLOW_DOWNGRADE.- An option name
Ask about exactly this one, whatever its group:
--expert=MAX_MESSAGE_SIZE,RECIPIENT_DELIMITER. Combines with a group word:--expert=high,DKIM_SELECTOR.
all is accepted as a synonym for insane, and none (or off)
clears a group word given earlier in the same SPEC. An unrecognised selector is
an error listing the valid names, not a silently empty selection.
--expert=help prints the list and exits.
Each option is emitted into the section that actually reads it — MYNETWORKS
onto the port-25 listener (never a submission listener), MAX_MESSAGE_SIZE
onto [pepsi-ingress], HELO_NAME onto the smarthost’s route
([pepsi-stage-relay-to-smarthost-mta-smarthost]) — and is
recorded in [pepsi-wizard] so a later re-run keeps it. Leaving an answer
empty removes the option and restores Pepsi’s own default.
RECIPIENT_DELIMITER is the one worth spelling out: it is written into the
global [pepsi] section rather than onto the local-delivery stages, because
pepsi-ingress (which has no stage section of its own) must read the same
value to decide whose mailbox quota a sub-addressed recipient belongs to, and
the stages’ own Locality parser falls back to [pepsi]. MAILBOX_QUOTA,
MAILBOX_OVER_QUOTA and CRYPTO_ALLOW_DOWNGRADE land there too.
–expert only controls which options are asked about. A value found by an MTA import is applied whether or not it was asked for: if Pepsi has the option, a migrated setting is never dropped for want of a question.
85.1.39.1.8. Commands¶
- import [MTA] [–root DIR] [–out DIR] [–alias-style STYLE]
Report what would be migrated from an existing MTA, writing nothing. With no MTA the installed one is detected (and, if several are, they are listed and one must be named). The output is the same migration report –wizard writes, plus the alias map that would be generated and the wizard answers the import would pre-fill; secrets are shown as a placeholder, never printed.
This command needs no Pepsi configuration — it is meant to be run before there is one, to see what a migration would produce.
- –root DIR
Read the MTA’s configuration from under DIR instead of
/. Useful for inspecting a backup or another machine’s/etccopied into place.- –out DIR
Write the report and the converted map files into DIR (an existing file is diverted to
<name>.importedrather than overwritten).- –alias-style STYLE
How bare alias names are qualified:
per-domain(default),wildcardorprimary; see Migrating from another MTA.
- run [-r | –reset]
Perform the full setup (validate, provision TLS certificates, install schema, generate keys, print DNS records). This is also the default action when no subcommand is given. It also provisions the proof-of-origin secret whenever
[pepsi-origin]has neither aSECRETnor aSECRET_FILEand is not switched off withENABLED = no— proof of origin is on by default, so the section need not be present: a random secret is written to asecrets.d/pepsi-origin.secretfragment (owned by thepepsiaccount, mode0640) and referenced from the main configuration with an@inline-secret@directive, keeping it out of that world-readable file; a re-run reuses the existing fragment rather than rotating the key. It is skipped, with a warning, when[pepsi-ingress] HOSTNAMEis unset (the name is bound into thePepsi-OriginMAC) or when the path of the configuration file to update is unknown.On the same principle it generates the
[pepsi-crypto]key-encryption key when there is none — but only after the schema is installed, and only when the database confirms that no wrapped private key exists yet. A missing fragment beside stored keys is a lost key, not a new install, and minting a fresh one would silently make every stored private key unopenable; so both “keys exist” and “I could not check” refuse loudly instead, naming restore-the-fragment as the fix.Before anything else, run secures any secret fragments beside the configuration — the
secrets.d/*.secretfiles the –wizard externalizes secrets into (see below) — giving each0640mode and handing it to the account that must read it:secrets.d/pepsi.secret(smarthost/LMTP passwords, the merchant access token) topepsi,secrets.d/pepsi-httpd.secret(theRESUME_AUTHORIZATION_TOKEN) topepsi-httpd,secrets.d/pepsi-ingress.secret(the SRSSECRET) topepsi-ingress,secrets.d/pepsi-crypto.secret(the[pepsi-crypto]KEY_WRAP_SECRET, current and retired) topepsi-crypto, andsecrets.d/pepsi-secure-link.secret(the[pepsi-secure-link] PEPPER) andsecrets.d/pepsi-list.secret(the[pepsi-list] UNSUBSCRIBE_SECRET) topepsi-httpdwith grouppepsi— the two fragments with two readers, since the portal serves what the stage sealed and the web endpoint verifies the unsubscribe token the delivery stage computed. The SRS secret is owned bypepsi-ingressbecause ingress reverse-decodes SRS return addresses; the SRS stage runs aspepsiand reads it through the sharedpepsi-ingressgroup (the Debian package makespepsia member — a source install must arrange the equivalent). The key-wrapping secret is the one whose loss cannot be recovered from: it opens every stored private key, so back it up separately from the database (see pepsi-keys(1)).This is the first step so that a run which stops later — a certificate that cannot be issued, an unreachable database — still leaves every secret readable by its service. Each fragment is then verified to have really ended up owned by that account, and any that did not is listed among the deferred steps: an unreadable fragment is otherwise invisible, because the configuration loader only warns about an
@inline-secret@file it cannot open, and the service starts and fails later with a misleading “… is not configured”.Last, run brings
pepsi-telemetry-client.serviceinto line with[pepsi] SHARE_TELEMETRY:systemctl enable --nowwhen telemetry is on. When it is off the unit is left as the operator left it — never started, and no longer stopped: a running daemon is dormant (no socket, nothing submitted), and keeping it is what lets the browser console offer to switch telemetry on. Either way run then sends thetelemetry_changednotification, so a running daemon follows the answer at once — turning telemetry off takes effect immediately.pepsi.targetdoes not start that unit — nothing in the targetWantsit, because a unit the target pulled in would run whatever the answer was, though the unit isPartOf=the target and so stops with the pipeline. It happens here, at the end, because theSYSTEM_IDis generated earlier in this same run; with telemetry on and no valid identifier the unit is armed anyway (the daemon stays dormant and says why) and the reason is logged. All of it is best-effort: a host with no systemd, no unit installed or no root is told the singlesystemctlcommand instead. See pepsi-telemetry-client(1).- -r, –reset
Drop all existing
pepsiobjects before re-creating them. DANGEROUS: all stored messages are irrecoverably lost. Signing keys on disk are not affected. Only a database built by a development version from a different copy of an unreleased patch ever needs this; an older release’s schema is upgraded in place (see schema).
- schema [–backup-dir DIR] [–if-installed]
Install or upgrade the database schema and re-apply the role grants, and nothing else: no configuration validation beyond
[pepsi-postgres], no certificates, no keys, no DNS. It therefore works with any configuration file that names the database –/etc/pepsi/pepsi.confon a mail host,/etc/pepsi-telemetry/pepsi-telemetry.confon a telemetry collector – and is what a package upgrade runs, before the services restart.The installer records the SHA-256 of every SQL file it applies, and the release it came from, in
pepsi.schema_file; every Pepsi program compares that record with the hashes compiled into it when it connects, and refuses to run (exit status 78) against a schema that is older, newer or built from different files. schema brings an older schema up to this release. It refuses, before changing anything and with exit status 78, a schema that is newer than this release (downgrades are not supported: older stored functions installed over a newer schema would break it), one built from a different copy of a patch file, and one with no record at all (built by a development version before the record existed; such a database has to be drained and re-created with run –reset). It also refuses SQL files inSQL_DIRthat are not the ones this binary was built with – a stale directory after a partial upgrade. Concurrent installers serialise on an advisory lock.- –backup-dir DIR
Before an upgrade changes anything, save the schema with pg_dump(1) (custom format; the
pepsiand_vschemas and the extensions, which the archive’s trigram index needs) as DIR/pepsi-OLD-VERSION-UTC-TIME.dump, mode 0600, run as the schema owner. Taken only when there is something to upgrade. If the dump fails, nothing is upgraded. Old dumps are never deleted. Restore one with pg_restore(1) into an empty database.- –if-installed
Do nothing when the database has no Pepsi schema yet. A new installation is set up with run; this is for maintainer scripts, which must not install a schema nobody asked for.
- check
Query live DNS and report, for each authoritative domain, whether the records actually published match what run would generate. It checks the DKIM selector records (one well-formed key record of the right
k=type whosep=public key matches the local key; see Prints DNS records under run), the SPF record (thev=spf1record lists every configuredPUBLIC_IP), the_dmarcTXT record (exactly one, and strictly valid — see Prints DNS records under run above), and the_mta-stsTXT record (present and a parseablev=STSv1record with anid=tag — theidvalue is operator-chosen and is not compared, though its syntax is: RFC 8461 §3.1 allows 1 to 32 letters and digits). Each record is reported as[ok],[MISSING],[MISMATCH],[INVALID]or[lookup failed]; every non-[ok]line is followed by an indented→line naming the exact record to publish and pointing back to run for the authoritative value. The command makes no changes, and its findings never set the exit status: it exits 0 however many records are missing or wrong, so read its output rather than its exit status. (Only a configuration that does not validate, or a resolver that cannot be built at all, makes it exit non-zero — it has nothing to report then.)[INVALID]is the verdict for a record that is published but that receivers discard, so that the domain is unprotected while looking configured. Its remedy differs from the others’: the record is repaired in place rather than replaced with the value run prints. Besides the DKIM findings described below, three checks produce it — a DMARC record that does not parse (or a second one at the same name), more than onev=spf1record (RFC 7208 §4.5 makes that a permanent error, so no SPF policy applies rather than the first one winning), and more than onev=STSv1record or one whoseid=is outside the allowed syntax. Av=spf1record ending in+allis reported as a[MISMATCH]: it is valid, it passes the “are my addresses listed?” test, and it authorises every host on the Internet to send as the domain, defeating the DKIM and DMARC records published beside it.Every record is checked twice: in the system resolver’s answer, and at each of the zone’s authoritative nameservers asked directly. A record the resolver returns correctly but some nameserver does not (a stale secondary, a lame or unreachable server) is a
[MISMATCH]naming the servers, with a remedy pointing at the zone’s distribution rather than at the record. A DKIM record with the wrong or missingk=, a misplacedv=, anh=/s=restriction excluding what Pepsi signs, or a second key record at the same name is[INVALID];t=yis a[MISMATCH]. When Ed25519 signing is on, a closingnote:explains why Google reports that selector asfail.A record may also be
[ok]and carry a note — ap=noneDMARC policy is valid, is the right place to start, and enforces nothing, so the report says so rather than leaving a domain to sit in monitoring mode indefinitely.Beyond the TXT records, for every domain an MTA-STS policy is served for it runs the two MTA-STS cross-checks described under run — the policy’s
mxset against the liveMXrecords, and a fetch ofhttps://mta-sts.<domain>/.well-known/mta-sts.txtcompared with the configured policy — and ends with a note giving theMTA_STS_MXline that would make them agree. For the[pepsi-srs] SRS_DOMAINit adds the deliverability check described under The SRS domain can receive its own bounces.- questions
Print the setup interview to stdout as JSON: the ordered steps, and for each question its identifier, kind (
text,bool,choice,list,secret,path,port,domain,integer,address,url), prompt, help text, default, whether it is required, the configuration option it maps to when it maps to exactly one, and the condition under which it is asked at all.This is the model both front-ends are described by — the terminal interview and the browser one pepsi-httpd serves at
/api/v1/setup/questions— and it is the schema an –answers file is written against. It needs no configuration — it is what you read before you have one.- apply [–once] [–idle SECS] | apply –clear
Perform the privileged setup work the browser interface has asked for. Run as root. See The applier’s trust model below before deploying this.
On a Debian system the systemd units that run this on demand are in the separate pepsi-httpd-admin package; removing it leaves this subcommand working from a root shell while making it unreachable from the web console. See Removing the capability: the pepsi-httpd-admin package below.
It reads intent rows from the
pepsi.setup_tasktable — each one a request from the administrative API for something in a closed set — checks that each may be acted on, does it, and records the outcome and its progress lines back into the row. It performs no other work and accepts no other input: in particular it never reads a command, a path to execute or a file to write verbatim from a task.- –once
Drain whatever is pending and exit. What a cron entry or a manual run wants.
- –idle SECS
Without –once, wait on the
setup_taskNOTIFYchannel after draining and exit once SECS pass with nothing to do (default 300). This is what makes socket activation work: pepsi-httpd connects to the doorbell socket after enqueuing, systemd starts this unit, it drains the queue and goes away again. A root process sitting permanently on a queue of work supplied by a web tier is exactly the standing privilege the design avoids.- –clear
Delete every row of
pepsi.setup_taskwithout executing any of them, report how many were removed, and exit. Refused together with –once or –idle: this is an administrative reset, not a draining mode.A task is a durable request to a process running as root and nothing expires it, so a queue that has been sitting there is a hazard rather than a backlog. The applier’s units are a separate package precisely so a deployment can run with nothing draining that queue — and rows can still reach it in that state, for instance from an operator with a root shell. Install the missing package months later and every one of them runs at the first doorbell ring, in the order it was asked for, against a deployment that has moved on. Consent does not keep, so installing
pepsi-httpd-adminruns this from itspostinst.Every row goes, not only the
pendingones. Only apendingrow can ever execute and arunningrow is orphaned by definition, so deleting just those two would be enough for safety — but a “clear” that left a console full ofdoneandrefusedrows would not be one, and nothing is lost by removing them: each request, refusal, success and failure is an independent durable audit entry, which this does not touch. The task’ssetup_task_logprogress lines follow it by cascade.A database with no Pepsi schema in it is reported as an empty queue rather than as an error — it has never held a task — so this is safe to run on a host that has not been set up yet.
Pass a
-cFILE for the draining modes: the applier writes that file and itssecrets.dfragments, and the path comes from its own command line and never from anything a task can influence. It is not strictly required — with no-cthe ordinary default search below applies, and the applier only fails when that finds nothing — but naming the file is the point: the default search consults$XDG_CONFIG_HOMEand$HOME, which is not what a root-run applier should be resolving. –clear executes nothing and so needs no such file.- bootstrap [–valid-for SECS] [–admin-url URL]
Mint the one-time credential that creates the first administrator account, and print it with the
curlcommand that uses it.A fresh install has no account, so nothing can log in to the console that would create one. This prints a bearer token holding the full administrator scope set, valid for SECS (default 3600) and accepted exactly once — enough for a single
POST /api/v1/accounts. It is consumed on presentation by an update only one racing request can win, so a token read by two people admits one of them.The scope list is not a choice:
POST /api/v1/accountsrefuses to issue a scope the caller does not hold outright, so a token holdingsetup:writealone could not create an administrator — and, since the token is consumed before the handler refuses, the attempt would spend it. The printedcurlasks for exactly the scopes the token was minted with, rendered from the same list, so the two cannot drift.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. Only its digest is stored, so it cannot be recovered — run the command again if you lose it. Running it again also revokes any outstanding bootstrap token.
A local administrator over the
ADMIN = yesUNIX-socket listener is identified bySO_PEERCREDand needs no credential at all, so an expired or lost bootstrap token is an inconvenience rather than a lockout.- visualize
Print the configured
[stage-*]pipeline to stdout as a Graphvizdotgraph. Each stage is one node, labelled with its configuration section name (e.g.stage-init) and thePROGRAMit runs with thepepsi-stage-prefix stripped (e.g.arc). A solid edge follows each stage’sNEXT_STAGEand a dashed redbounceedge itsBOUNCE_STAGE. The command only reads the configuration (no DNS, schema or filesystem changes); pipe it throughdotto render, for example:pepsi-setup -c /etc/pepsi/pepsi.conf visualize | dot -Tpng -o pipeline.png
85.1.39.1.9. The applier’s trust model¶
pepsi-setup apply is the one component in Pepsi that runs as root on behalf of an HTTP request.
85.1.39.1.9.1. Why the console does not act¶
pepsi-httpd drops privileges before it accepts a single connection, and must
never be able to regain them: it parses attacker-supplied input for a living. It
therefore cannot write /etc/pepsi/pepsi.conf, hand a secrets.d fragment
to the account that reads it, run certbot, create a database role or generate key
material — all of which setup has to do.
So it does not try. It writes a row saying what should be true, and the applier decides how. Root stays off the network entirely: it is reached through a database table, not through a socket that speaks a protocol.
85.1.39.1.9.2. The closed task list¶
There are ten task kinds and there will not be a general one. An arbitrary-command kind would be a root shell over HTTP wearing a task name.
write-configRender the configuration from an answer set — the same answers the interview collects — validate it exactly as
pepsi-setup runwould, then write the file and its secret fragments and hand each fragment to its owning account. The answers are the intent; the file is the applier’s own rendering of them. Nothing a task supplies is written verbatim.write-secretStore one credential in the
secrets.dfragment its reader owns, preserving every other secret in that fragment. It refuses any[section] OPTIONpair that is not already a credential the secrets layer manages — otherwise it would be an arbitrary option write into a root-owned file every service reads.obtain-certificateObtain the TLS certificates the configuration asks for. Named hosts narrow what is reported, not what is attempted: letting a task introduce a host would make certbot’s arguments task-supplied.
install-schemaInstall or migrate the schema. ``reset`` is refused: dropping the schema destroys every queued message and makes every wrapped private key unopenable, which is not something a web form may ask for. Use
pepsi-setup run --reseton a console.provision-rolesCreate the database login roles and re-apply their grants.
generate-keysGenerate the per-domain DKIM material. A task may narrow the domain set, never widen it: a domain this deployment does not serve gets no signing key.
run-preflightRun read-only environment probes and record the report. Which probes is an allow-list (
ports,dnssec,dns-records), so “run a preflight” cannot become “run this”.generate-identityGenerate a server-managed OpenPGP key (an MTA key) for one address (parameters
addressand an optional booleanvks, which defaults to[pepsi-keys] VKS_PUBLISHand requests a key-server upload). The address must be at a[pepsi-ingress] ACCEPTED_DOMAINSdomain and aKEY_WRAP_SECRETmust be configured. Always performed on request, even next to the user’s own key or a revoked key. The applier does the work as thepepsi-cryptorole (root becomes that account for the connection, as pepsi-keys(1) does), because only that role may write the private column.register-client-keyRegister the user’s own public key (an MUA key,
custody = client) for one address (parametersaddressandkey, an OpenPGP public key as text, at most 64 KiB). The address must be served, the key must be one OpenPGP certificate one of whose User IDs names the address, and a fingerprint the address already has, retired or not, is not registered again. It goes through the applier although no private material is involved: a key registered as the user’s own becomes the address’s public face and verifies signatures as the user’s, so a compromised web tier must not be able to plant one.revoke-identityRevoke one local identity (parameters
address,identity_idand an optional one-linereasonof at most 1024 bytes, recorded as the revocation reason). The identity must belong toaddress. The retirement rule is pepsi-keys identity revoke’s: a signing-only key’s private half is destroyed, an encryption key’s is kept so mail already encrypted to it stays readable. Performed as thepepsi-cryptorole, because destroying a private half writes the private column. Revoking an identity that is already revoked changes nothing and succeeds; the result says which happened.reset-otpRemove one address’s second factor (parameter
address), which is also how a second factor locked by ten wrong codes is unlocked; its owner then enrols again. Performed as thepepsi-cryptorole, the only one grantedpepsi.otp_key. Removing one that does not exist succeeds and says so.
These four act on one address’s keys rather than on the deployment, and are authorised differently (see The two gates).
The owner’s second factor. generate-identity, register-client-key
and revoke-identity accept an optional otp parameter (six digits). For a
task admitted on own:<address> alone – the address’s owner asking, not an
operator holding keys:write – the applier checks it against the address’s
second factor (see pepsi-keys(1), otp) before doing anything: an
address without one needs no code, and a missing, wrong, replayed or locked code
fails the task with the reason. The attempt is recorded (a wrong code counts
towards the lock) and a code is used up even if the task then fails.
85.1.39.1.9.3. What is deliberately absent¶
There is no restart, reload or shutdown task, and there will not be one —
the same decision that keeps service control out of the console. The cost is
real: a configuration change that needs a component restarted ends with the
operator restarting it. Where possible, components pick changes up by themselves
instead (the config_changed notification does this for the database
configuration overlay).
85.1.39.1.9.4. The two gates¶
A task is acted on only when both of these hold:
its recorded
scopescontainsetup:write— the authorisation the administrative API established for the principal that asked. For the four key kinds,generate-identity,register-client-key,revoke-identityandreset-otp, the scope required is insteadkeys:write, or – for all butreset-otp, which is the operator’s alone –own:<address>for exactly the address named in the task’s parameters: a user managing their own key is not configuring the server, andsetup:writealone does not suffice. The address is taken from the parsed parameters, never from the scope list, so a principal confined to one address cannot name another; andits
written_byis thepepsi-configdatabase role — proof that the row reached the table through the one path we control. That column is forced fromcurrent_userby aBEFORE INSERTtrigger, so it is a fact about the connection rather than a claim in the payload, and there is no spelling of theINSERTthat gets a forged value past it.
pepsi-setup run enforces the second half against PostgreSQL on every run:
only pepsi-config may INSERT into setup_task; pepsi-httpd may
only SELECT (it enqueues through the separate [pepsi-admin] CONFIG_DB
connection); and no account that processes mail may touch the table at all.
A stage worker parses hostile mail for a living and must not be able to put work
in front of a root process. That is verified, not asserted: a mismatch is a hard
error naming the role and the privilege.
85.1.39.1.9.5. What it cannot promise¶
The applier never saw the HTTP request, so it cannot re-authenticate the
principal. scopes is a claim recorded by the administrative surface; what the
applier verifies is that the claim was recorded by the only role permitted to
record it. A compromise of pepsi-httpd together with its configuration
database credential therefore reaches the closed task list above. That is the
residual risk, and it is why the list is closed and small rather than convenient.
85.1.39.1.9.6. Removing the capability: the pepsi-httpd-admin package¶
The applier is only ever started by systemd socket activation on the doorbell
pepsi-httpd rings. Those two unit files —
pepsi-setup-apply.socket and pepsi-setup-apply.service — are therefore
the whole of the path from an HTTP request to a change under /etc/pepsi, and
Debian ships them in their own binary package, pepsi-httpd-admin.
pepsi Recommends it, so a default installation has it and the browser
console works as documented. An operator who administers this deployment from a
terminal removes it:
apt remove pepsi-httpd-admin
and pepsi stays installed. A source install has the same lever as
make install INSTALL_ADMIN_UNITS=no.
What that removes is the mechanism, not a button. With the units gone nothing
starts the applier, so a setup_task row is inert however it got there:
pepsi-httpd detects the missing doorbell and serves its configuration and
setup pages read-only, refusing the matching API mutations with
503 setup_applier_unavailable — see pepsi-httpd(1), The privileged
applier, and doing without it.
pepsi-setup itself stays in the pepsi package and is unaffected.
pepsi-setup run, pepsi-setup --wizard, pepsi-setup check and even
pepsi-setup apply --once still work — root running the program is root
acting, which is not what this restricts. What it removes is the web tier’s
ability to cause that to happen.
Note
Package removal deliberately does not revoke the database grant that lets
the pepsi-config role INSERT into pepsi.setup_task, for three
reasons: a maintainer script would need a reachable cluster and a superuser
credential it does not have, so a hardening step could turn into a removal
failure; the grant is not the boundary that matters, because a row nothing
drains is inert and the applier’s two gates apply regardless; and a later
reinstall would come back with the console broken in a way whose error message
points at the socket rather than at the grant.
A site that wants the belt as well as the braces can do it by hand, and
pepsi-setup run (which re-applies the grants) puts it back:
REVOKE INSERT ON pepsi.setup_task FROM "pepsi-config";
85.1.39.1.9.7. Auditability¶
Every task is a durable row: who asked, what was requested, when it started and
finished, what it produced, and what went wrong. Every admission, refusal,
success and failure is additionally written to the audit log
(setup.task.requested, setup.task.refused, setup.task.done,
setup.task.failed), which is append-only for every component that processes
mail. Progress lines are streamed into setup_task_log as the work proceeds,
so a certbot run or a schema install can be watched without the applier holding
an HTTP connection.
The applier’s writes are announced, so nobody watching a task has to ask the
database again and again. Two triggers in procedures.sql notify with the task
id as payload: setup_task_progress for every setup_task_log line, and
setup_task_done when a task becomes done, failed or refused –
whichever writer makes that transition, the applier settling a task or
reaping one an earlier applier left running. The applier itself issues no
pg_notify; pepsi-httpd(1) listens on both channels and wakes the
requests waiting on that task (GET /api/v1/setup/tasks/{id}?wait= and the
console’s task page). The setup_task channel that wakes the applier is a
different one and is not fired by either.
85.1.39.1.10. Global Options¶
These global options precede the subcommand (a trailing flag is rejected).
- -c FILE, –config FILE
Read the configuration from FILE instead of searching the default locations (see FILES). pepsi-setup also rewrites this file in place when it auto-fills TLS certificate paths, the reverse-proxy socket, the proof-of-origin secret, the key-encryption-key reference, the telemetry system id and the probed
[pepsi] MAILBOX_FS_QUOTA. When -c is omitted it resolves the same default location the config was loaded from — normally/etc/pepsi/pepsi.conf— and rewrites that, so a barepepsi-setup runbehaves likepepsi-setup -c /etc/pepsi/pepsi.conf run. It only reports the path as unknown when no configuration file exists at any default location.- –no-certbot
Do not invoke
certbotto obtain missing TLS certificates. The certbot path layout is still filled into the configuration when needed; a certificate that is then absent is deferred, not fatal — the host is listed in the end-of-run summary with an actionable message, and the rest of the run (schema, keys, DNS) proceeds. Use this when certificates are managed by other means, and note that a successful exit therefore does not assert that every configured certificate is present.- –no-reverse-proxy
Do not auto-integrate with an existing front HTTP server. pepsi-httpd then binds port 443 directly instead of being switched to a UNIX socket behind a reverse proxy (see step 3 of the Description). Use this when no other web server occupies 80/443, or when you wire the reverse proxy by hand.
- -y, –yes-to-all
Assume “yes” to every interactive prompt setup may raise (currently the offer to install the Dovecot drop-in, step 4), so a run never blocks waiting on input — useful in scripts and non-interactive deployments. Mutually exclusive with -n.
- -n, –no-to-all
Assume “no” to every interactive prompt: setup takes no offered action and instead prints what it would have done (e.g. the Dovecot drop-in to deploy by hand), then defers it. Also makes the run fully non-interactive.
- –wizard
Run the interactive configuration wizard (see Wizard). The configuration is written to the -c path, or to
/etc/pepsi/pepsi.confwhen no -c is given. Unlike the other modes, this does not require an existing configuration file. It takes no subcommand (pepsi-setup --wizard runis refused): at the end it offers to run the full setup itself. The options below that say “with –wizard” are refused without it, except--expert=help.- –force
With –wizard, overwrite an existing configuration file that has no importable
[pepsi-wizard]answers without prompting for confirmation.- –expert SPEC
With –wizard, also ask about the deeper Pepsi options the interview normally decides for you. SPEC is a group (
high,insane), a comma-separated list of option names, or both;--expert=helplists them and exits. See Expert options.- –answers FILE
With –wizard, answer every question from the JSON object in FILE instead of asking: the keys are the question identifiers pepsi-setup questions prints (plus the expert option names), and a question the file does not answer takes the wizard’s default, as pressing Enter would. An unknown key or an answer of the wrong shape is an error. Nothing is read from stdin, and every other prompt takes its default too — in particular the closing “Run setup now” offer, so run pepsi-setup run afterwards. See Answering without a terminal.
- –import MTA
With –wizard, import defaults from this existing MTA (
postfix,exim,sendmail,qmail,stalwart) instead of offering a menu of what was detected. Unlike auto-detection this also applies when a configuration file already exists, and it works on a non-interactive stdin. See Migrating from another MTA.- –no-import
With –wizard, do not look for an existing MTA configuration at all.
- –import-root DIR
With –wizard, read the MTA to import from under DIR instead of
/— a backup, or another machine’s configuration copied into place. The same option the import sub-command spells –root.- -L LOGLEVEL, –log LOGLEVEL
Set the logging verbosity. LOGLEVEL is one of
error,warn,info,debugortrace(default:info).- -v, –verbose
Show log messages from all sources, including third-party libraries.
- -h, –help
Print a usage summary and exit.
- -V, –version
Print the version and exit.
85.1.39.1.11. Exit Status¶
- 0
Successful completion.
- 1
An error occurred: a malformed configuration file, an invalid domain name or
PUBLIC_IPentry, a configuration file that could not be written, a failed database connection, or a key that could not be written. The reason is written to the log.A TLS certificate that is missing and could not be obtained is not in this list: it is deferred and reported in the end-of-run summary (step 4), and the run still succeeds.
85.1.39.1.12. Files¶
When –config is not given, the first existing file from the following list is used:
$XDG_CONFIG_HOME/pepsi.conf$HOME/.config/pepsi.conf/etc/pepsi/pepsi.conf/etc/pepsi.conf
Note the last two: every Pepsi program prefers the canonical
/etc/pepsi/pepsi.conf over the bare /etc/pepsi.conf, because the
packaging, the systemd units and @inline-secret@ all name the subdirectory —
a stale copy directly in /etc must not shadow the file the running services
read. When more than one of these exists, pepsi-setup logs which file it is
using and which ones are being ignored.
Generated key material is stored below the configured KEY_DIR (default
/var/pepsi/keys), one directory per domain containing dkim.rsa.key and
dkim.ed25519.key.
Beside the configuration file, –wizard writes aliases (the alias map,
converted from the previous MTA’s routing tables when one was imported),
username.map (the submission-identity map), secrets.d/*.secret (the
externalized secrets) and, after a migration, import-report.txt. A file that
already exists is never overwritten by an import: the converted content is
written to <name>.imported instead.
85.1.39.1.13. Examples¶
Bootstrap a fresh deployment and capture the DNS records to publish:
pepsi-setup -c /etc/pepsi/pepsi.conf run > pepsi-dns.zone
Re-run after an upgrade that ships new migrations (keys and DNS are unchanged unless a new domain was added):
pepsi-setup -c /etc/pepsi/pepsi.conf run
Validate the configuration and reset the database from scratch:
pepsi-setup -c /etc/pepsi/pepsi.conf run --reset
After publishing (or changing) DNS, verify what is actually live against what Pepsi expects:
pepsi-setup -c /etc/pepsi/pepsi.conf check
Render the stage pipeline to a PNG to review how messages are routed:
pepsi-setup -c /etc/pepsi/pepsi.conf visualize | dot -Tpng -o pipeline.png
Before migrating, see what would be taken from the mail server this host runs today (nothing is written):
pepsi-setup import
Migrate from Postfix, answering the deeper questions too:
pepsi-setup --wizard --import postfix --expert=high
Inspect what a migration would produce from another machine’s /etc, saved
under /mnt/oldhost:
pepsi-setup import postfix --root /mnt/oldhost --out /tmp/migration
85.1.39.1.14. See Also¶
pepsi-config(1), pepsi.conf(5), pepsi-ingress(1), pepsi-stage-relay-to-smarthost(1), pepsi-keys(1), pepsi-keydisc(1)
85.1.39.1.15. Bugs¶
Report bugs to the Pepsi issue tracker.