70.1.30. pepsi-setup¶
provision the Pepsi database, signing keys and DNS for a deployment
- Manual section:
1
70.1.30.1.1. Name¶
pepsi-setup - install the schema, validate config, create DKIM keys and print DNS records.
70.1.30.1.2. Synopsis¶
pepsi-setup [GLOBAL-OPTIONS] [run [-r | –reset]]
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).
70.1.30.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. If anything is wrong it reports the problem and exits non-zero without changing anything. 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).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.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=www-data), 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, as before). 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 (www-data) 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)). 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. Note that an already-issued certificate is never re-issued, so a deployment that predates this (or that adds a served domain) must extend its certificate by hand withcertbot certonly --cert-name <host> --expand -d ….Then, for each such host whose certificate is not already on disk, it runs
certbot certonly --standalone(HTTP-01 over TCP/80, registered without an email) to obtain it. This step is skipped for any listener whose paths are set explicitly. 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.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. Three roles are deliberately narrower than the rest: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.ingress,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 the point of the exercise. 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, 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(itsmxis the[pepsi-ingress] HOSTNAME), so pepsi-setup prints only the_mta-stsTXT record (with the policyid) and a reminder to pointmta-sts.<domain>at the server. 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.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, which is why this one is worth the words.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.)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 (saygrothoff.organnounced inEHLOwhile its address reverse-resolves tozkey.eu) 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.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 OAuth token refreshing. 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.
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.
70.1.30.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.
70.1.30.1.5. Wizard¶
For a first deployment, –wizard lowers the entry barrier: instead of editing the configuration by hand, it runs 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, Signing 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 and relay it to a smarthost), 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 it also 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 handles the rest, 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, 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.
Earlier versions asked this as a four-way LOCAL_SUBMISSION question
(none/loopback/sendmail/all) that bundled the two together. An
existing answer file is migrated: loopback and all become
TRUST_LOOPBACK = yes, the other two no, and the socket is served
regardless.
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, counted from 0 in section order within each owning
service) 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). With socket activation you must install the
matching .socket units (a pepsi-ingress.socket listing ListenStream=
for the enabled ingress listeners in FD_INDEX 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. It then asks whether to send SMTP TLS Reports
(TLSRPT, RFC 8460; default yes), which emits the [pepsi-tlsrpt] section.
Declining both — and not enabling the paywall — produces a minimal host with
no HTTP server at all: the pepsi-httpd sections are emitted only when
something needs them (the MTA-STS policy host(s) or the paywall /resume
webhook).
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 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 the paywall is off, the wizard instead asks
whether to merely learn a correspondent whitelist from outgoing mail; and,
on the outbound path, whether to offer the by-e-mail settings editor (see
pepsi-stage-edit-settings). SRS (on the inbound path, when a smarthost is
configured) is wired automatically and is not asked about.
70.1.30.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;
a filter for submitted mail is configured by hand, or 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).
70.1.30.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.
70.1.30.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. 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
70.1.30.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.
70.1.30.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.
70.1.30.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). One consequence worth knowing: 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.
70.1.30.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@.
70.1.30.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.
70.1.30.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-separated list of group words and/or option names:
highThe options an administrator plausibly tunes:
MAX_MESSAGE_SIZE,MYNETWORKS,RECIPIENT_DELIMITER,MAX_LIFETIME,DMARC_ENFORCE.insaneEverything in the registry, adding
HELO_NAME,TLS_CERT,TLS_KEY,DNS_TIMEOUT,MAX_CONNECTIONS,MAX_OPEN_SOCKETS,DKIM_SELECTORandKEY_DIR.- 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.
--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), RECIPIENT_DELIMITER
onto the local-delivery stages, HELO_NAME onto the smarthost stage — 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.
–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.
70.1.30.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 when
[pepsi-origin]is present without one: 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.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, andsecrets.d/pepsi-crypto.secret(the[pepsi-crypto]KEY_WRAP_SECRET, current and retired) topepsi-crypto. 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,disable --nowwhen it is not — so an operator who turns it off has the daemon stopped rather than merely told to stop. That unit is the one Pepsi daemon not pulled in bypepsi.target, because a unit the target starts would run whatever the answer was. It happens here, at the end, because the daemon refuses to start without theSYSTEM_IDgenerated earlier in this same run; with telemetry on and no valid identifier the unit is left alone (starting it would only giveRestart=on-failuresomething to loop on) 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.
- 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 (the published
p=public key matches the local key), 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→ fix:(or→) line naming the exact record to publish and pointing back to run for the authoritative value. The command makes no changes and always exits 0; it is a diagnostic report, so read its output rather than its exit status.[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. 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.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.The MTA-STS policy file (served over HTTPS at
https://mta-sts.<domain>/.well-known/mta-sts.txt) is not fetched by this check; only DNS records are verified.- 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, which is the point: 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 (an operator with a root shell, or an older release that did not refuse every path through the web tier). 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.
A
-cFILE is required for the draining modes: the applier writes that file and itssecrets.dfragments, and the path deliberately comes from its own command line rather than from anything a task can influence. –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
setup:writeand nothing else, valid for SECS (default 3600) and accepted exactly once — enough for a singlePOST /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.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
70.1.30.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. This section says exactly what it will and will not do, and why the web console cannot act on its own.
70.1.30.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.
70.1.30.1.9.2. The closed task list¶
There are seven 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”.
70.1.30.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 and is stated rather than papered over: 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).
70.1.30.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; 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.
70.1.30.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.
70.1.30.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 was never the thing being restricted. What is gone 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";
70.1.30.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.
70.1.30.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 and the telemetry system id. 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, but if a certificate is then absent pepsi-setup fails rather than trying to obtain it. Use this when certificates are managed by other means.- –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.- –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.- –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.
70.1.30.1.11. Exit Status¶
- 0
Successful completion.
- 1
An error occurred: a malformed configuration file, an invalid domain name or
PUBLIC_IPentry, a TLS certificate that is missing and could not be obtained (see step 2), a failed database connection, or a key that could not be written. The reason is written to the log.
70.1.30.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/pepsi.conf
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.
70.1.30.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
70.1.30.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)
70.1.30.1.15. Bugs¶
Report bugs to the Pepsi issue tracker.