85.1.39. pepsi-setup

provision the Pepsi database, signing keys and DNS for a deployment

Manual section:

1

85.1.39.1.1. Name

pepsi-setup - install the schema, validate config, create DKIM keys and print DNS records.

85.1.39.1.2. Synopsis

pepsi-setup [GLOBAL-OPTIONS] [run [-r | –reset]]

pepsi-setup [GLOBAL-OPTIONS] schema [–backup-dir DIR] [–if-installed]

pepsi-setup [GLOBAL-OPTIONS] check

pepsi-setup [GLOBAL-OPTIONS] visualize

pepsi-setup –wizard [–force] [–expert SPEC] [–answers FILE] [–import MTA [–import-root DIR] | –no-import] [-c FILE]

pepsi-setup [GLOBAL-OPTIONS] questions

pepsi-setup [GLOBAL-OPTIONS] apply [–once] [–idle SECS]

pepsi-setup [GLOBAL-OPTIONS] apply –clear

pepsi-setup [GLOBAL-OPTIONS] bootstrap [–valid-for SECS] [–admin-url URL]

pepsi-setup [GLOBAL-OPTIONS] import [MTA] [–root DIR] [–out DIR] [–alias-style STYLE]

GLOBAL-OPTIONS include –no-certbot, –no-reverse-proxy, and -y/-n to answer prompts non-interactively (see Global Options).

85.1.39.1.3. Description

pepsi-setup is the administrative bootstrap tool for a Pepsi deployment. In a single invocation it:

  1. Validates the configuration. It parses the [pepsi] and [pepsi-ingress] sections (including listeners) and the [stage-*] pipeline — checking that a [stage-init] exists, that every NEXT_STAGE/BOUNCE_STAGE resolves, and that each stage’s program configuration (including upstream-MTA routing) parses — and checks that every domain name is well-formed and that each PUBLIC_IP entry is a valid IP address. It warns when a relay stage’s PUBLIC_IP is 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 -all it 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 no PUBLIC_IP at all (the record degrades to a bare v=spf1 -all). It also refuses the reserved .invalid placeholder identity shipped in the sample configuration: if any of HOSTNAME, ACCEPTED_DOMAINS, [pepsi] ARC_DOMAIN or a stage SERVER_NAME still 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 run pepsi-setup --wizard. It warns about every option set in a known section that nothing reads — a misspelt option silently takes its default — suggesting the closest real option name, and about options renamed since an earlier release (see “Renamed options” in pepsi.conf(5)). Sections it does not know are left alone. If anything is wrong it reports the problem and exits non-zero without installing a schema, generating a key or publishing anything. (Validation is not literally the first thing that runs: the steps below that repair the configuration file — the reverse-proxy integration, the certbot paths, the proof-of-origin secret, the telemetry identifier and the MAILBOX_FS_QUOTA probe — necessarily precede it, because validation is run against their result.) The operator-edited map files (below) are deliberately not part of this step, so an unrelated map typo can never block the run. If the pipeline uses a pepsi-stage-detect-language stage 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 — install pepsi-stage-detect-language before starting the service, or the detect-language stage will fail to spawn. Two further non-fatal warnings flag likely misconfigurations: a pepsi-stage-block-language stage with a BLACKLIST but an empty WHITELIST and a non-negative THRESHOLD (which bounces all neutral-language mail — see pepsi-stage-block-language(1)), and a non-progressing NEXT_STAGE cycle (a chain of NEXT_STAGE edges that loops back on itself, so a message would circle until MAX_LIFETIME; legitimate re-entry through BOUNCE_STAGE/RESTART_STAGE is not flagged). It also warns when a TARGETS entry admits a uid below /etc/login.defs UID_MIN on a stage whose setuid helper refuses such uids — pepsi-stage-relay-to-maildir(1), pepsi-stage-dot-forward(1), and pepsi-stage-auto-pay(1) in WALLET_MODE = local-user — since a recipient matched only by that entry fails in the helper on every message.

    The end-to-end cryptography settings are validated here too: the [pepsi] CRYPTO_* options and the [pepsi-crypto] key-store section (see pepsi.conf(5)). Individually valid but jointly wrong combinations are errors — CRYPTO_SMIME_SHARED_KEY = yes with an elliptic-curve CRYPTO_SMIME_ALGORITHM (one EC key doing both ECDSA and ECDH is rejected by several S/MIME clients), a CRYPTO_GENERATE_RSA_BITS below CRYPTO_MIN_RSA_BITS (the deployment would generate keys it then refuses), an implausibly small CRYPTO_MIN_RSA_BITS, a non-positive IDENTITY_VALIDITY_DAYS, and a KEY_WRAP_KEY_ID that could not name its own KEY_WRAP_SECRET_<ID> option. A [pepsi-crypto] section that says anything at all but has no KEY_WRAP_SECRET is likewise an error, and the message names the @inline-secret@ fragment the secret should have come from together with its owner and mode: the @inline-secret@ mechanism only warns about a fragment it cannot read, so an unreadable one is otherwise indistinguishable from an unset option. A secret shorter than 16 characters is refused — it is the single key that opens every stored private key. A deployment that does no end-to-end cryptography configures none of this and is not nagged about it.

    For each pepsi-stage-encrypt(1) stage it also checks the pEp preset (ENABLE_PEP) and the key self-service. It warns when ENABLE_PEP is on but [pepsi-crypto] AUTO_CREATE_IDENTITY = no (a valid site veto, but then no sender gets a key automatically; say ENABLE_PEP = no to make it explicit) or no KEY_WRAP_SECRET is configured (no sender can be given a key); when RESPONSE_STAGE is unset (users are not told when a new key is registered for their address, and the e-mail key commands to pepsi-keys@<domain> are off); and when [pepsi-ingress] USERNAME_MAP grants a wildcard, naming those accounts: they may send as any matching address but can never register their own key for one. It refuses a RESPONSE_STAGE naming no stage, and, when RESPONSE_STAGE is set, a TEMPLATE_DIR lacking the key-registered.en.body or keys.en.body fallback template.

    Setup also repairs an orphaned fragment. The secret and the @inline-secret@ directive that pulls it in are two halves of one statement living in two files, and the fragment outlives the configuration that referenced it — so a regenerated or hand-written pepsi.conf leaves a deployment where secrets.d/pepsi-crypto.secret is 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 as root does 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-default KEY_WRAP_KEY_ID that went with the lost file, so the warning names the id it asserted; set it by hand if the key was stored under another.

  2. Prepares the operator-edited map files. On every run it walks the configured lookup tables — the [pepsi-ingress] USERNAME_MAP and each pepsi-stage-aliases stage’s ALIASES map. A file that does not exist is created from a commented starter template (mode 0644) 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 --wizard mode this runs only after pepsi.conf has been written, so a map problem never prevents the wizard from writing the configuration.

  3. 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 --standalone cannot bind 80. Rather than fight for the ports, pepsi-setup detects the front server (via ss), confirms it does not already serve any of the mta-sts.<domain> host names, and then (a) keeps [pepsi-httpd-listener-https] socket-activated but plain-HTTP (SERVE = systemd, MODE = plain), stripping any direct-bind MODE = tls + TLS_CERT/TLS_KEY and the now-unneeded [pepsi-httpd-cert-*] sections, and drops a pepsi-httpd.socket override (/etc/systemd/system/pepsi-httpd.socket.d/10-reverse-proxy.conf) that rebinds ListenStream from 443 to a UNIX socket at /run/pepsi/httpd.sock (SocketGroup= the front server’s group: www-data on Debian, nginx/apache/httpd elsewhere), and (b) writes a reverse-proxy site (pepsi-mta-sts) into the front server’s sites-available (enabling it in sites-enabled; conf.d for non-Debian nginx) that terminates TLS for those host names and forwards them to the socket. The front server now terminates TLS, so the certificates (this step and the SMTP MX certificate of the next step) are obtained through that server’s certbot authenticator plugin (--nginx/--apache) rather than --standalone. This step is a no-op when no front server is present, and is skipped entirely with --no-reverse-proxy (in which case pepsi-httpd binds 443 directly). It is idempotent: once the pepsi-mta-sts site 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 the mta-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-run pepsi-setup run once resolved. The pepsi-httpd service must provide the socket directory (the shipped systemd unit does so with RuntimeDirectory=pepsi) and the front server’s user must be able to reach the group-shared socket.

  4. Provisions TLS certificates. For every listener that terminates TLS — the [pepsi-ingress-listener-*] sockets with MODE = tls/starttls, the TLS [pepsi-httpd-listener-*] sockets, and each [pepsi-httpd-cert-*] SNI certificate — that has no TLS_CERT/TLS_KEY set, it fills the options in with the standard certbot layout (/etc/letsencrypt/live/<host>/fullchain.pem and privkey.pem), writing them into the configuration file in place (comments are preserved). The <host> is the [pepsi-ingress] HOSTNAME for SMTP/HTTP listeners and the certificate section’s first SNI host for [pepsi-httpd-cert-*]; listeners sharing a host share one certificate.

    The HTTPS listener’s certificate additionally covers openpgpkey.<domain> for every domain in ACCEPTED_DOMAINS, because that is where the Web Key Directory’s advanced method is fetched from (pepsi-httpd(1)), and — when [pepsi-autoconfig] turns mail autoconfiguration on — autoconfig.<domain> as well, which is the first URL a mail client tries. They are added to the listener’s own certificate rather than given a [pepsi-httpd-cert-*] section of their own, since that certificate is the fallback served whenever SNI matches nothing — so one certificate covers both the SNI hit and the fallback.

    The ingress listeners’ certificate covers every MX host an MTA-STS policy permits — the [pepsi] MTA_STS_MX set across all served domains — for a reason with no counterpart on the HTTPS side. RFC 8461 §4.1 requires the certificate an MX presents to match the name the MX record gave the sender, and pepsi-ingress(1) serves exactly one certificate per listener: it records the client’s SNI but never selects on it. A host reachable as mx.example.org and mail.example.net therefore cannot answer with two certificates; it needs one certificate carrying both names. Wildcard mx entries (*.example.net) are skipped with a warning — HTTP-01 cannot satisfy a wildcard, and passing one to certbot would fail the whole request including the names that would have worked — so a deployment publishing one must supply that certificate itself.

    Then, for each such host, pepsi-setup runs certbot certonly --standalone (HTTP-01 over TCP/80, registered without an email) when the certificate is not on disk — and, when it is, reads the Subject Alternative Names out of it and re-runs certbot (--expand, same --cert-name) if they do not cover every name above. That matters because a certificate’s name set is not fixed at first issuance: adding a served domain, turning on autoconfiguration or naming a new MX host all widen it. A certificate whose names cannot be read is left alone rather than re-issued, since “I could not tell” must not spend a rate-limited issuance. Every name is checked against DNS, not just the certificate’s own --cert-name: certbot validates each -d separately and fails the whole request if one of them cannot be reached.

    Which certificates pepsi-setup may drive certbot for is decided by comparing the configured TLS_CERT/TLS_KEY against the layout paths it would itself have written for that host. It cannot be “is TLS_CERT set?”, because a successful first run sets it — after which every listener would look operator-configured and no certificate could ever be widened again. Paths equal to the layout are pepsi-setup’s to keep current; anything else is a certificate the operator chose and is never touched, and a half-configured section (one of the two options set) is left to the strict validation, which reports it better. A certificate that is missing and cannot be obtained is deferred rather than fatal: setup continues (installs the schema, generates keys, prints DNS records) and lists every deferred certificate in a summary at the very end, with the concrete next step and a reminder to re-run pepsi-setup run once the obstacle is cleared. Re-running is idempotent and only completes what is still missing. A certificate is deferred when --no-certbot was given, certbot is not installed, pepsi-setup is not running as root (so it cannot bind TCP/80), the host’s DNS does not resolve to a configured PUBLIC_IP (so the challenge would fail), or certbot itself 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 configured PUBLIC_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 --config was 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.conf naming every certificate and key that server reads as a LoadCredential= 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-run pepsi-setup run after adding, moving or removing a certificate. When the drop-in changes, pepsi-setup reloads systemd and try-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.sh that re-applies the grant on every future issuance/renewal (certbot rotates archive/<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 (the acl package), and an ACL-capable filesystem — a missing prerequisite is deferred with the fix, not fatal. Certificates outside certbot’s tree (operator-set TLS_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 pepsi for LMTP, pepsi-ingress for SASL. This catches the common case of a socket that exists but is unreachable: Dovecot’s shared /run/dovecot/auth-client is 0600 dovecot and cannot be opened by pepsi-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 dedicated unix_listener owned by the Pepsi service user (mode = 0600, so the shared socket’s permissions are left untouched) — then validates the merged configuration with doveconf and reloads Dovecot. When it cannot (not root, no terminal to ask at, doveconf rejecting the result, or no local Dovecot), it prints the drop-in and defers so you can deploy it by hand. Pepsi’s default SASL_PATH is this private socket, /run/dovecot/auth-client-pepsi. This check runs on every pepsi-setup run, not only under --wizard. -y/--yes-to-all installs the drop-in without prompting; -n/ --no-to-all skips the install and just prints it — either makes the run fully non-interactive.

  5. 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 is pepsi-ingress.socket that fails (systemctl status pepsi-ingress.socket reports Result: resources), mail keeps being delivered by the other server using its alias and virtual maps, and every change to pepsi.conf therefore 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 configured PORT for a SERVE = tcp listener, and the shipped socket unit’s FD_INDEX mapping (0 → 25, 1 → 465, 2 → 587) for a SERVE = systemd one — and names the offending process (with its pid) when the holder is neither systemd nor 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 starting pepsi-ingress.socket.

    Checks that the resolver validates DNSSEC. In the same phase, and only when the configuration asks for DANE, pepsi-setup asks the system resolver a question it knows the answer to and looks for the AD bit. Pepsi trusts a validating resolver rather than validating DNSSEC itself, so a resolver that never sets AD makes DANE and DANE-based key discovery find nothing at all, silently. Also deferred, not fatal.

  6. Installs the database schema. Every Pepsi component shares one database (the [pepsi-postgres] section) and the one pepsi schema; pepsi-setup is the sole installer. The schema is a single patch series (pepsi-NNNN.sql) with one procedures.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 pepsi schema. The pipeline role pepsi (and pepsi-crypto) gets the whole schema, less the carve-backs below; the three services that are not the pipeline are then taken back to what an audit of their code says they use, as the very last step, and setup refuses to finish if PostgreSQL disagrees:

    pepsi-ingress

    INSERT into pepsi.workqueue and pepsi.workqueue_body (plus SELECT of the ids workqueue_add returns and of the two generated size columns, header_octets and octets, that the queue admission check sums), SELECT on config_override and mailbox_quota, INSERT on the two append-only logs. It cannot read any message already queued.

    pepsi-telemetry

    Its own pepsi.telemetry table.

    pepsi-httpd

    Everything the console, the portal, the list site and the archive use, but on pepsi.workqueue only the envelope, state and routing columns — never headers or the body, and it cannot repoint a message’s body_id; on pepsi.workqueue_body only INSERT (the web forms send mail) and DELETE (deleting a message drops the body only it carried; the foreign key refuses a body another message still references), never SELECT of the body — and nothing at all on settings, whitelist, vacation_reply, payment_request_reply, the secretary tables, telemetry, origin_nonce or auto_pay_spend; the statistics, quota, DNS and TLS-report tables and the telemetry daemon’s liveness row (telemetry_client) read-only.

    The default privileges for tables a later patch creates are revoked from pepsi-ingress and pepsi-telemetry as well. See the manual’s “Security model” for the reasoning and its “Test Suite” for how the boundary is tested.

    Three further roles are deliberately narrower still:

    pepsi-whitelist

    Granted SELECT, INSERT and DELETE on pepsi.whitelist and 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-keydisc

    The 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/DELETE on pepsi.peer_key (filling that cache is its whole job, and the conflict rule has to be able to replace a row), SELECT/INSERT/UPDATE on pepsi.key_request, EXECUTE on the keydisc_* functions and the two crypto_* helpers they call — and, on pepsi.workqueue, SELECT plus a column-level UPDATE (status, timeout).

    Those two columns are the interesting part. They are exactly enough to move a message parked on an address from paused back to pending, which is what keydisc_resolve and keydisc_sweep do (plain SECURITY INVOKER functions, 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 to pepsi.crypto_identity at all.

    pepsi-crypto

    The account that custodies end-to-end private key material. It receives the ordinary pipeline grants across the schema plus the pepsi.crypto_identity.private_wrapped column — and it is the only role that does.

    That last column is what the exercise is for. Immediately after the blanket grants, pepsi-setup revokes the table-level SELECT/INSERT/ UPDATE on pepsi.crypto_identity from every ordinary service role — pepsi (the dispatcher and every stage worker), pepsi-ingress, pepsi-httpd and pepsi-telemetry — and grants the same three back column by column for every column except private_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. (DELETE has 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 from information_schema rather 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.d fragment (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).

  7. Generates signing keys. For each domain Pepsi is authoritative for — the union of [pepsi-ingress] ACCEPTED_DOMAINS, the domains of each stage’s SERVER_NAME / POSTMASTER, an explicit SIGNING_DOMAIN, the [pepsi-srs] SRS_DOMAIN of an SRS stage, a self-service stage’s fixed RESPONSE_FROM, and the [pepsi] ARC_DOMAIN if set — it creates an RSA-2048 and an Ed25519 DKIM private key under KEY_DIR if they do not already exist. Existing keys are never overwritten, and key files are created mode 0600 in per-domain directories created mode 0700.

  8. 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>._domainkey for RSA and <selector>-ed25519._domainkey for Ed25519), an SPF policy (v=spf1 …) built from the PUBLIC_IP addresses of every relay stage (unioned; see below), a DMARC policy (_dmarc.<domain> TXT — see below), and an MTA-STS record (_mta-sts.<domain> TXT, unless MTA_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) at https://mta-sts.<domain>/.well-known/mta-sts.txt (its mx set comes from [pepsi] MTA_STS_MX, falling back to the [pepsi-ingress] HOSTNAME), so pepsi-setup prints only the _mta-sts TXT record — whose id is derived from that domain’s own policy, so two domains with different mx sets advertise different ids — and a reminder to point mta-sts.<domain> at the server. Unlike every other record here, the MTA-STS one is printed only for the [pepsi-ingress] ACCEPTED_DOMAINS. DKIM, SPF and DMARC authenticate an identity this host sends as, so they cover the wider set above; MTA-STS is a promise about mail coming in, and only an accepted domain has one kept — pepsi-httpd(1) answers /.well-known/mta-sts.txt for those hosts alone, and the certificate (and any front-server site) is obtained for those names alone. A domain that is only a sending identity — a relay SERVER_NAME, an explicit SIGNING_DOMAIN, the [pepsi-srs] SRS_DOMAIN — but that still has a published _mta-sts TXT record gets a ; DELETE: comment instead: what it advertises is a policy no component serves, so the request falls through to whatever else answers on that address. When [pepsi-tlsrpt] RUA is 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’s SubjectPublicKeyInfo — the 3 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, equivalent 3 1 1 TLSA records are also printed for the pepsi-httpd(1) HTTPS listeners (per SNI host, plus the fallback certificate under HOSTNAME), 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, an OPENPGPKEY (RFC 7929) or SMIMEA (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; use pepsi-keys dns <address> for a specific one. Publish key records only in a DNSSEC-signed zone — an unsigned one is a key from whoever can answer for the zone.

    A last block concerns mail autoconfiguration. When [pepsi-autoconfig] is on, every served domain’s autoconfig.<domain> host that does not already resolve is listed with a reminder to point it at the pepsi-httpd(1) host. Unlike the Web Key Directory reminder above this is close to a total failure rather than a degradation: a client tries https://autoconfig.<domain>/mail/config-v1.1.xml first, and the /.well-known/ form on the apex — which needs no new record — is optional in the draft, so a client that does not try it simply finds nothing.

    The SPF record is built from PUBLIC_IP, which is a per-stage option read from every relay stage — detected by its PROGRAM (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 own PUBLIC_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 sets PUBLIC_IP, the record is a bare v=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=spf1 records. 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 leftover v=spf1 include:<old provider> is the commonest thing an MTA migration leaves behind.

    The DMARC record is the one pepsi-setup cannot derive: which policy a domain wants is an operator decision, not a consequence of the configuration. So this block is about the record being usable, and it behaves differently depending on what is already published:

    • Nothing published. v=DMARC1; p=none is suggested, with a comment explaining that it only monitors and should be tightened to quarantine or reject, and that rua=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=reject must be repaired where it stands rather than replaced by the p=none this 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 tag aspf with the value s adkim=s and 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 missing p= tag, and an rua=/ruf= address written without its mailto: 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 single setup: DNS records checked and they are OK line 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 a p=none. (Use the check subcommand for live verification of the published TXT records.)

    “Already published with the correct value” means correct at every authoritative nameserver of the zone, not just in the system resolver’s answer. For each TXT record pepsi-setup also finds the zone’s NS set and asks each server directly, without recursion: a secondary still serving an old key, a delegated server that no longer serves the zone (a lame delegation) or one that does not answer at all makes receivers see the right record only some of the time, so the record is printed with a ; NOTE: line naming each server that disagrees (and a warning is logged). A wrong answer from the resolver that every authoritative server contradicts is marked as a stale cache. When no authoritative server can be reached — a network that lets only the local resolver out on port 53 — a warning is logged once and the resolver’s answer alone decides, as before.

    A DKIM selector record is judged the way a verifier reads it (RFC 6376 §3.6.1), not by its p= alone: exactly one key record at the name, v= (if present) first and DKIM1, a k= naming the key type (an Ed25519 key with no k= tag fails everywhere, since the tag defaults to rsa), no h=/s= restriction excluding sha256/email, no t=y (testing mode, under which a signature counts for nothing) and a p= equal to the local key. Only the selectors [pepsi] DKIM_ALGORITHMS signs with are printed and checked, plus the one the ARC seal needs on the ARC_DOMAIN. Next to the Ed25519 record — and as a log line when every record is already correct — pepsi-setup notes that Gmail, Microsoft 365 and Yahoo do not verify Ed25519 signatures and that Google’s DMARC aggregate reports list them as fail: expected and harmless, since the RSA signature supplies DMARC’s aligned pass, and removable with DKIM_ALGORITHMS = rsa.

    In the same phase pepsi-setup also checks the reverse DNS (PTR) of each pepsi-stage-relay-to-internet PUBLIC_IP — this host’s own egress addresses (a pepsi-stage-relay-to-smarthost PUBLIC_IP is the smarthost’s, so it is skipped). Each address is judged against the name the owning stage announces, its SERVER_NAME (falling back to the ingress HOSTNAME when a stage sets none), because that is the pair a receiver compares. A PUBLIC_IP is correct only when its PTR exists, 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 PTR host 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 no PTR at 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 (say example.org announced in EHLO while its address reverse-resolves to example.net) passes every other check here and still has its mail refused;

    • a PTR that could not be looked up at all — an undelegated reverse zone or unreachable DNS. Correctness could not be established, and an address with no usable PTR is 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_IP lives in the IP owner’s zone (in-addr.arpa / ip6.arpa), the usual fix is to ask your ISP or hosting provider to set the PTR to 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 the PTR is already one of your own domains, the message offers the other direction too — adopt the PTR name as this host’s identity ([pepsi-ingress] HOSTNAME and every stage’s SERVER_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 same PTR lookup (see –wizard below), so an interview-generated configuration starts out agreeing with reverse DNS.

    DNS as the Internet sees it. Every check above is made from this host, which is the one place a zone served from its own network always looks healthy: behind NAT, a query from inside for a reverse zone whose nameserver is this very machine is answered by the LAN, by the router or by the host itself, while the Internet times out at a router that forwards port 25 but not port 53 — or reaches a nameserver that serves a smaller zone than its parent delegates, which resolvers that minimise their queries (RFC 9156) receive REFUSED for. Receivers then defer every message with cannot find your reverse hostname, and the local check says the PTR is correct.

    So pepsi-setup asks again through the public resolvers named by [pepsi] PUBLIC_RESOLVERS (by default Google, Cloudflare and Quad9, each over IPv4 and IPv6; see pepsi.conf(5)): the PTR of every PUBLIC_IP, forward-confirmed through the same resolver; each domain’s MX set and the TXT records the check subcommand verifies; and the addresses of the ingress HOSTNAME. All of them are asked, because they differ — in the address family they use to reach a nameserver and in how they walk a delegation — and a zone that is reachable only over IPv6, or lame only for a minimising resolver, fails for some of them and not for others. A receiver uses one of those too. Any resolver that cannot resolve a name, finds no record, or sees a different answer than this host (split-horizon DNS) is a warning, naming the resolver and — when it sends one — its RFC 8914 extended error (No Reachable Authority: At delegation …). When the zone’s nameservers resolve to this host’s own or private addresses, the warning says so and names the usual causes: a port forward that is missing or disabled for UDP or TCP port 53, an IPv6 firewall rule, or a served zone that is not the delegated one. The check subcommand reports these as [UNREACHABLE] (a correct record the Internet cannot read) and lists the PTR verdicts in a reverse DNS group of their own.

    Resolvers that cannot be reached from this host are dropped silently (a host without IPv6 connectivity loses the IPv6 ones); when none can be reached, a warning is logged once and only the local view is checked. PUBLIC_RESOLVERS = none turns this check off.

    MTA-STS cross-checks. Everything else pepsi-setup verifies about MTA-STS is self-consistent by construction: the _mta-sts TXT record carries an id derived from the policy, and the policy is derived from the configuration, so comparing them can only succeed. Two things are not derived from the configuration, and under MTA_STS_MODE = enforce either one being wrong silently stops all inbound mail for the domain. Both are checked on every pepsi-setup run, and reported by the check subcommand and the web console; like the PTR findings they are warnings and never fail the run.

    • The policy’s ``mx`` set against the live MX records. A policy is a promise that senders may connect only to the hosts it names. A sender resolves the domain’s MX, finds a host the policy does not permit, and refuses to deliver — at every compliant sender at once, with no symptom on this side beyond mail that never arrives. This is the failure the MTA_STS_MX fallback invites, because HOSTNAME is the EHLO greeting name and need not be a name any MX record points at. The warning names the hosts on each side and prints the single MTA_STS_MX line that would make them agree, qualified per domain when the served domains have different MX hosts, along with the alternative of changing the MX record instead and the advice to fall back to MTA_STS_MODE = testing until they do agree. Also reported here: a domain with no MX record, an RFC 7505 null MX on a domain we accept mail for, an MX target that is a CNAME (forbidden by RFC 5321 §5.1, and senders may refuse it), a policy entry that matches no live MX host (harmless, but it widens what senders will connect to), and a DOMAIN:HOST entry naming a domain that is not in ACCEPTED_DOMAINS — which applies to no policy at all and is what a typo in the option looks like.

    • Whether the policy can be fetched. pepsi-setup requests https://mta-sts.<domain>/.well-known/mta-sts.txt and compares what comes back with the policy this configuration defines — semantically, so line endings and key order are not differences. Redirects are not followed, because RFC 8461 §3.3 forbids a sender from following them, and a policy served only behind one is a policy no sender can read. A correct TXT record in front of a policy nobody can fetch is not a hypothetical: the server is one unreachable socket, misconfigured front server or missing certificate away from every sender getting a 502 instead, while every unit stays active and nothing is logged — and senders that already cached a policy keep enforcing the old one for up to max_age, so the failure is not even self-limiting. A 5xx is reported with the front-server socket as the thing to check.

      When the published name cannot be reached from this host — it does not resolve yet, or the network will not let the host reach its own public address — the request is retried against this machine over the loopback interface, keeping the published name in SNI and Host so the certificate is still validated against the name a sender would use. That result is labelled as a local probe, because it says what a sender would get once mta-sts.<domain> resolves here; it is the reason a purely local misconfiguration is still reported on a host whose DNS is not yet published.

    The SRS domain can receive its own bounces. When a stage runs pepsi-stage-srs(1), pepsi-setup checks that [pepsi-srs] SRS_DOMAIN has an MX record — or, failing that, an A/AAAA record, RFC 5321 §5.1’s implicit MX. An SRS address is a return path: the rewrite makes this host the bounce destination for mail it did not write, and the HMAC in the address exists so that pepsi-ingress(1) can decode a returning bounce and relay it to the original sender. A domain with neither record ends that round trip in silence — forwarding works, SPF passes at the next hop, and every bounce is discarded by the MTA that tried to return it, so the original sender is never told their message did not arrive. An RFC 7505 null MX (MX 0 .) is the same answer stated deliberately, and an MX target that is a CNAME is reported for the reason it is everywhere else (RFC 5321 §5.1, RFC 2181 §10.3). The warning prints the record to publish, naming a host the accepted domains already route mail to, and offers the two alternatives: point SRS_DOMAIN at a domain that already receives mail here, or drop the stage if nothing is forwarded off-site. Like the checks above it is a warning and never fails the run.

    The check exists because the omission is invisible from every other angle. The SRS domain is a sending identity, so it joins the set that gets DKIM keys and SPF and DMARC advice; pepsi-setup suggests no MX record for any domain, that being the operator’s own routing decision. An operator who publishes everything they are told to publish therefore ends up with two of the three records the domain needs, and nothing anywhere says so.

    Forward-confirmation queries authoritative DNS only and deliberately ignores the local /etc/hosts file — a mail host commonly lists its own name there against 127.0.1.1 or 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.

  9. Verifies the payment backend (optional). When a [pepsi-payments] section configures a GNU Taler merchant backend, pepsi-setup checks it over HTTP: GET /config must identify a taler-merchant backend (so the URL is correct) and GET /private/orders?limit=1 must return 200 (so the MERCHANT_ACCESS_TOKEN is correct). It then ensures a pepsi-resume webhook exists that POSTs to pepsi-httpd’s /resume endpoint whenever an order is paid — creating it if absent, or, if a pepsi-resume webhook 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.

  10. Reminds about credentials that expire out of band. When any smarthost MTA uses AUTH = oauth, pepsi-setup validates the corresponding [pepsi-helper-token-refresh-<name>] secret sections it can read (it runs as root) 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 of pepsi.target. When one uses AUTH = gssapi it likewise reminds that the Kerberos credential cache has to be renewed out of band (a keytab plus k5start or cron) and be readable by the dispatcher’s worker, sanity-checking any cache path it can see. Both reminders go to the log (standard error), after the zone output, so they can never corrupt a redirected zone file.

All behaviour is driven by an INI-style configuration file (see pepsi.conf(5)). The same configuration file is shared with the other Pepsi tools.

85.1.39.1.4. Privileges and ownership

pepsi-setup may be run either directly as the database owner (for example su pepsi-owner -c 'pepsi-setup … run') or as root. When it is run as root it manages two distinct identities itself, so the resulting objects are owned by the right accounts:

  • Database objects are created as the pepsi-owner role. Before any database work (schema install, role provisioning, settings validation, payment-backend check) pepsi-setup assumes the effective identity of the pepsi-owner user; PostgreSQL peer authentication then connects as the same-named role, so the pepsi schema, its tables, sequences and functions are owned by pepsi-owner. The pepsi-owner user/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 pepsi account. The per-domain DKIM keys are generated back as root (mode 0600) and then chowned to the pepsi user — the dispatcher account that signs outbound mail — so the signing stages can read them. When the pepsi user does not exist this is a logged no-op.

When pepsi-setup is not run as root, neither switch happens: it operates entirely as the invoking user, which must therefore be the database owner and be able to read/write KEY_DIR.

Note

ARC sealing (performed by pepsi-stage-arc(1) on inbound mail) reuses the DKIM key of the [pepsi] ARC_DOMAIN, so no separate ARC key or DNS record is generated: ARC verifiers fetch the seal’s public key from the same DKIM record. When ARC_DOMAIN is set, pepsi-setup simply includes it among the key/DNS domains.

85.1.39.1.5. Wizard

For a first deployment, –wizard replaces hand-editing with a short interactive interview and writes a complete, already-validated configuration file.

The interview is presented as a sequence of numbered steps (each shown as a Step X/Y banner with a one-line description, in colour on a capable terminal — set the NO_COLOR environment variable to disable it), grouping related questions: Server identity, Existing mail system, Local delivery, Submission authentication, Trust loopback, Forwarded mail, Transport security, Content filtering, Anti-spam paywall, Account self-service and End-to-end cryptography (plus Expert options under –expert). Which steps appear (and therefore Y) follows from the chosen direction and whether a local Dovecot is detected. The manual’s The Wizard chapter draws the pipeline each direction produces and lists what every question decides. When stdin is a real terminal, prompts are line-edited with the usual GNU-readline key bindings (Home/End, Ctrl-Left/Ctrl-Right to skip words, Ctrl-A/ Ctrl-E/Ctrl-K/Ctrl-U/Ctrl-W, and Up/Down to recall earlier answers); a piped (non-terminal) stdin — as used by the test suite and scripts — falls back to plain line reads. Each answer is validated the moment it is entered (hostnames and domains must be fully-qualified, the postmaster a valid address, sending IPs real IP literals, ports numeric, the payment hold a h/m/s duration, and so on), so a mistake is reported at its own prompt and corrected on the spot rather than only at the final whole-config validation.

It first asks the direction the host serves — inbound (receive mail for our domains from the Internet, delivering it locally and/or relaying it onward), outbound (send our own users’ submissions directly to the Internet) or both (the default; split on state.local_origin with a routing stage) — which determines the SMTP listeners (port 25 for inbound, 465/587 for outbound) and the relay stages that appear. It then asks for the hostname, accepted domain(s), postmaster address, public sending IP(s) and the database connection string, proposing sensible defaults (the parent of the hostname as the domain). The hostname default is taken from reverse DNS: the wizard detects this host’s public addresses (the same detection described for the sending IPs below), looks up their PTR records, and offers the first name that forward-confirms back to the address and does not look auto-generated. Only if there is no such name does it fall back to /etc/hostname. This is deliberate — a receiving mail server compares the name announced in EHLO against the PTR of the address the session comes from and rejects when they disagree (HELO host does not match rDNS), while /etc/hostname is a purely local label no receiver ever sees. On a host that answers to several names the two routinely differ, and reverse DNS lives in the IP owner’s zone, so it is normally the half that cannot be changed. When the offered default (a previous run’s answer, or /etc/hostname) contradicts the PTR, the wizard says so and names both before asking. The public sending IP(s) default is auto-detected and offered for confirmation: the hostname’s published A/AAAA records unioned with the host’s own globally-routable interface addresses, and — only when that finds no public IPv4 (a NAT host with a private IPv4) — the WAN IPv4 reported by the local UPnP gateway via upnpc (miniupnpc), if installed. Only public addresses are suggested: a private/loopback/link-local address is never pre-filled (behind NAT the detected value is the public egress IP, not the LAN address). The operator confirms or edits it before anything is written.

On the inbound path the next question is whether an existing mail system — Microsoft Exchange or Microsoft 365 — sits behind this host. Answering yes makes Pepsi a transparent gateway: it becomes the MX and hands everything to the tenant endpoint, so there are no local mailboxes and the local-delivery questions below are skipped. It then asks for that endpoint’s host and SMTP port (warning that it must be the tenant-specific endpoint, not the domain’s public MX, which is this host and would loop) and for the IP address family to reach it over (any/ipv4/ipv6 — Exchange Online refuses mail from an IPv6 address with no reverse DNS, so ipv4 is the escape hatch when IPv6 reverse DNS is not delegated).

Otherwise, on the inbound path it asks which local-delivery method(s) to use — Maildir (pepsi-stage-relay-to-maildir) and/or LMTP to an MDA (pepsi-stage-relay-to-lmtp) — and, when Maildir is enabled, whether to honour per-user ~/.forward files (pepsi-stage-dot-forward; the generated section forwards to addresses only, with ALLOW_PIPE / ALLOW_FILE left off).

Still on the inbound path, it asks whether this host should answer mail for recipients who are away (pepsi-stage-vacation, default no). Answering yes only puts the stage in the pipeline — after alias expansion and after the spam gates, so a notice is never sent on behalf of an alias or in reply to mail the gates would have refused. Nobody is answered until leave dates exist, and those are ordinary per-address configuration rather than a wizard answer: a user can set their own by e-mail, an operator with pepsi-settings set <address> vacation VACATION_RANGES …, and a holiday everybody shares with pepsi-config set --scope domain:<domain> stage-vacation VACATION_RANGES …. The generated section therefore leaves VACATION_RANGES unset (with the syntax in a comment) and points RESPONSE_STAGE at the shared signing tail so each notice is signed and relayed. On a host with no local delivery — a pure forwarder, or an Exchange front — it also sets VACATION_TAG = none: tagging the subject would otherwise rewrite a header covered by the author’s DKIM signature and by this host’s own ARC seal, which the next hop would see fail. See pepsi-stage-vacation(1).

It then asks for the upstream smarthost (optional when local delivery or an Exchange system handles the served domains, required otherwise).

Two questions then decide how mail leaves this host with a signature of its own. The first concerns programs running on this host — cron, mail(1), logwatch, git send-email or a web application — which are otherwise refused with 550 5.7.1 Relaying denied for any recipient outside a served domain.

The wizard does not ask whether to serve the UNIX-domain submission socket that /usr/sbin/sendmail uses. pepsi-ingress always serves it, deriving it from [pepsi-sendmail] SOCKET, so no listener section is written and there is nothing to answer wrongly. The kernel tells pepsi-ingress which account opened the socket, so a caller can only ever send as itself: the login is run through USERNAME_MAP exactly as a SASL username would be. See pepsi-sendmail(1), and SOCKET = none to switch it off.

What it does ask, on a host serving both directions only (the answer becomes MYNETWORKS on the port-25 MX listener, which only an inbound host has, and is only worth asking of a host that also submits), as a yes/no question defaulting to no, is whether to additionally trust loopback:

TRUST_LOOPBACK

Add MYNETWORKS = 127.0.0.0/8 ::1/128 to the port-25 listener (merged with any networks set through the expert options, never duplicated). This trusts a network position rather than a user, so any local process — including a compromised web application — can send as anyone; and it is also what lets a message this host relays to itself (a served domain whose public MX is this host) re-enter as a fresh submission and loop. Say yes only for software that insists on speaking SMTP to port 25 and cannot be pointed at the socket.

Either way the resulting messages count as locally originated (state.local_origin) and are therefore DKIM-signed by the outbound path.

On the inbound path it asks whether to DKIM-sign the mail this host forwards (defaulting to yes): an alias expanding off-site, a ~/.forward pointing at another provider, or the whole pure-relay path. Those messages leave down the inbound tail, which the outbound [stage-dkim-sign] never touches, so without this they arrive at the next hop with our SRS envelope but dkim=none from us. Answering yes emits a second signing stage, [stage-dkim-sign-relay], between the SRS rewrite and the destination, with SIGNING_DOMAIN pinned to the primary served domain. That pin is deliberate: signing is fail-closed, and the author’s domain belongs to somebody else — deriving the signing domain from a foreign From: would fail every forwarded message for want of a key. The host’s own identity — the ARC sealing domain ([pepsi] ARC_DOMAIN), every relay/bounce SERVER_NAME and the fallback listener certificate — is the configured hostname throughout, distinct from the accepted recipient domains (which scope SRS, MTA-STS and the TLS-report addresses).

The way those listeners bind their ports depends on the host. When the system is managed by systemd (detected by the presence of /run/systemd/system), the wizard writes socket-activated listeners (SERVE = systemd with a FD_INDEX per listener) so the privileged ports are bound by .socket units and inherited by the unprivileged service; otherwise it falls back to direct binds (SERVE = tcp with BIND_TO / PORT). The ingress listeners always take FD_INDEX 0 for port 25, 1 for 465 and 2 for 587, matching the shipped pepsi-ingress.socket, which lists all three whichever direction the host serves. With socket activation you must install the matching .socket units (a pepsi-ingress.socket listing ListenStream=25, 465 and 587 in that order, and a pepsi-httpd.socket for port 443); the wizard prints a reminder after writing.

Two transport-security questions follow. On the inbound path it asks whether to serve an MTA-STS policy (default yes): when enabled it sets MTA_STS_MODE = enforce and adds a [pepsi-httpd-cert-*] section for each accepted domain’s mta-sts.<domain> policy host (served over HTTPS by pepsi-httpd), whose certificate pepsi-setup provisions via certbot like any other — you must point each mta-sts.<domain> name’s DNS at the server for the policy to be reachable. The hosts the policy permits are not asked for: they are read from the served domains’ live MX records and written as [pepsi] MTA_STS_MX (left out when no MX record exists yet, so the policy falls back to HOSTNAME; see MTA-STS cross-checks above). It then asks whether to send SMTP TLS Reports (TLSRPT, RFC 8460; default yes), which emits the [pepsi-tlsrpt] section.

The same step closes with the one question that is not about this deployment at all: whether to share anonymous feature-usage telemetry with the Pepsi project ([pepsi] SHARE_TELEMETRY, default no). It is opt-in, so the prompt explains what would be sent before asking — a random 256-bit SYSTEM_ID and counts of which Pepsi features this host has exercised, never addresses, message data, host names or IP addresses — and pressing Enter through the whole interview therefore shares nothing. Saying no disables the entire path: no identifier is generated, setup never starts the daemon, and one the operator started is sent dormant. See pepsi-telemetry-client(1).

The pepsi-httpd sections are always written, whatever else is declined: HTTPS on port 443 with the host certificate the SMTP listeners use (serving the MTA-STS policy when that is on), and the administrative console on /run/pepsi/admin.sock for the pepsi-admin group. pepsi.target starts the server and its socket on every host, so a configuration without a listener would leave it failing and restarting.

A short feature menu follows, asked only where it applies to the chosen direction: whether to block mail by detected language (and, if not, whether to still localize bounce messages, which enables language detection). When detection is enabled it first prints the full list of supported ISO 639-1 codes and asks which languages the detector should recognise, accepting * (the default) for every language the detector supports and rejecting any unsupported code on the spot (rather than only at the final validation). Only a detected language can then be allowed or blocked, so when blocking is on it next asks for a single combined language policy in a +allow/-block syntax — e.g. +en +fr -ja -zh allows English and French while blocking Japanese and Chinese, and a single -* (or +*) wildcard covers every other detected language (so +en +fr -* allows only English and French). The literal none may be listed to decide the fate of undetected mail. This one answer is split into the stage’s WHITELIST and BLACKLIST options. It then asks whether to ask unknown senders to confirm by reply (SECRETARY, pepsi-stage-secretary(1), default no), and about the GNU Taler anti-spam paywall (which also enables the correspondent whitelist so known contacts skip the payment request). When the paywall is enabled the wizard asks for the merchant backend URL first and immediately verifies it over HTTP (GET /config must identify a taler-merchant backend), reading back the currencies the backend supports — the price must be quoted in one of them, so the URL has to be known before the price is asked. It then asks for the access credential: either a ready-made access token (recognised by its secret-token: prefix, and verified against the backend) or the instance password, which the wizard exchanges for a scoped access token via POST /private/token (requesting the all scope so the token can also manage the pepsi-resume webhook). Finally it asks the price per message as a space-separated list of Taler amounts (e.g. EUR:1 CHF:1 KUDOS:10), validated against the backend’s currencies; each amount becomes one choice in the generated v1 order ORDER_CHOICES, letting the payer pick a currency. When both the paywall and confirm-to-send are off, and the host also has an outbound path, the wizard instead asks whether to merely learn a correspondent whitelist from outgoing mail.

On the outbound path a self-service step then asks up to two independent yes/no questions, both defaulting to no: whether account owners may edit their own per-address settings by e-mail (pepsi-stage-edit-settings(1)) — asked only on a host serving both directions, because every stage an account owner may edit is an inbound one — and whether they may operate their own GNU Taler wallet by e-mail — balance, top-up and peer-to-peer payments, served by the same pepsi-stage-auto-pay(1) that pays inbound payment demands.

The last step, asked whichever direction was chosen, is end-to-end cryptography (default no): whether this gateway should encrypt and decrypt mail itself (OpenPGP and S/MIME), so users need no client plugin. Saying yes puts pepsi-stage-encrypt(1) and pepsi-stage-decrypt(1) in the pipeline; nothing is encrypted until a recipient’s key is known, and keys come from discovery (pepsi-keydisc(1)), from pepsi-keys(1), or from the mail correspondents send us. On the inbound path it then asks whether to learn correspondents’ keys from that mail (Autocrypt, default yes), which adds pepsi-stage-autocrypt-learn(1), and whether to re-encrypt what the gateway decrypted to the user’s own mail-client key before filing it (REENCRYPT, default yes), which adds pepsi-stage-reencrypt(1) in front of local delivery. On the outbound path it asks whether to enable pEp-style automatic encryption (PEP, default yes): every sender at a served domain gets an OpenPGP key on their first message, advertised in an Autocrypt: header, and mail is signed inside the encryption. It states the cost before asking – eager key creation puts a wrapped private key for everybody who sends mail into the database, and their public key into the Web Key Directory, whether or not they ever use encryption – and writes the answer explicitly, ENABLE_PEP = yes or ENABLE_PEP = no, into [stage-encrypt] in pepsi.conf, where it takes effect (no config.d file sets it). When accepted it asks whether to also attach the sender’s key as a file (ATTACH_KEYS_AS_FILES, default no), written only when chosen. The encrypt stage is always given RESPONSE_STAGE = dkim-sign, so the key-registered notices and the replies to the e-mail key commands are signed and relayed like any other outgoing mail. The browser setup interview asks the same questions. The key store, key discovery and the key-encryption key are provisioned by the rest of setup regardless of the answer.

SRS is wired automatically on every inbound path, whatever the relay destination, with a generated secret; only its SRS_DOMAIN is asked (on the same step as the forward-signing question, defaulting to the primary served domain).

85.1.39.1.5.1. Mail filters (milters) found on this host

On a host that receives mail, the wizard scans it for milter daemons and offers each one it finds as a pepsi-stage-milter stage (see pepsi-stage-milter(1)). It never installs one, and never starts or confines the daemon: as under sendmail and Postfix, the filter keeps its own package, unit and user account, and Pepsi is only a client. The scan is skipped on an outbound-only host, where there is no inbound path to place a filter on, and every filter it finds is placed on the inbound path. A filter for submitted mail is configured by hand, or — on a host that also receives — carried over from an imported non_smtpd_milters.

Seven filters are recognised: milter-greylist, milter-regex, clamav-milter, spamass-milter, rspamd (through its rspamd_proxy milter worker), mimedefang and amavisd-milter.

Each is looked for in three stages, and a filter is offered only when all three succeed:

  1. Installed — its binary is on $PATH or in the usual sbin directories, or a systemd unit file for it exists.

  2. Listening somewhere — a socket is read from the filter’s own configuration file (clamav-milter.conf’s MilterSocket, greylist.conf’s socket, an /etc/default fragment’s SOCKET= or -p flag, Rspamd’s bind_socket), then from its unit’s ExecStart, then from the paths its distribution ships.

  3. Answering — setup completes a real milter option negotiation with each candidate socket and takes the first that succeeds.

The handshake is what makes the offer trustworthy: an installed package proves nothing about a stopped daemon, and a wrong guess in the path lists costs nothing, because a socket that does not answer is simply never offered. It also settles what the filter is allowed to do. ALLOW_ACTIONS must grant every action a filter asks for or pepsi-stage-milter refuses the message by design, and the filter states that set during negotiation — so setup offers everything purely to hear the answer, and writes out precisely what was asked and nothing more. A filter that only inspects is granted none; MIMEDefang is granted the envelope actions exactly when the site’s own Perl policy uses them.

Each accepted filter becomes one stage, placed by what it does rather than by the order it was found: policy and greylisting first, then virus scanning, then spam scoring, then the general-purpose frameworks — and all of them after [stage-decrypt] (so a filter sees plaintext) and before the whitelist, paywall and Autocrypt stages (so those can branch on whatever a filter tagged). Filters carried over from an MTA import have no known purpose and therefore run after all of these, keeping the relative order that MTA ran them in.

Rejected mail is dropped rather than bounced. Pepsi runs filters post-queue, so a REJECT applies to a message pepsi-ingress has already answered 250 to: it cannot refuse the SMTP session the way the filter did under an MTA, and bouncing would send backscatter to whatever address forged spam named. Each detected filter’s REJECT_STAGE therefore points at a generated [stage-discard-rejected] (a pepsi-stage-discard with DISPOSITION = failure, BOUNCE = no); the verdict is still recorded in state.milter and in the log. Setting REJECT_STAGE = bounce restores notification, and the generated section says so. A filter that came from an MTA import keeps bounce, since telling the sender is the behaviour being carried over.

One recognised filter is offered but not proposed: milter-greylist defaults to no. Greylisting works by refusing delivery inside the session and betting that a real MTA retries where a spam engine does not — and post-queue there is no session to refuse and Pepsi itself is what retries, so the triplet ages out and the message is delivered whatever sent it. The spam-blocking effect is zero and only the delay on each new correspondent remains. It is still offered, because a site may run milter-greylist for other policy its ruleset carries.

Filters whose function Pepsi already performs are not offered: opendkim, openarc, opendmarc, SPF policy daemons and postsrsd. Running one alongside Pepsi means two components signing or judging the same message. They are not silently skipped — when one is found, the scan says so and names the Pepsi feature that replaces it, so that a scan which found nothing to offer is distinguishable from a scan that did not run. Spam classification is deliberately not in this category: Pepsi has its own opinions about spam, but they are different opinions from a Bayesian scorer’s, and an operator may reasonably want both.

Each answer is remembered under its own MILTER_* key (MILTER_CLAMAV, MILTER_RSPAMD, …) in [pepsi-wizard]. Only filters this run actually found are written there; a re-run scans again.

The generated configuration is validated through exactly the same checks as run before it is written, so the wizard never produces a file that would later fail to load. If validation does fail, the wizard prints the offending error and re-runs the interview with your previous answers pre-filled as the defaults, so you can correct the broken option(s) without re-entering everything. The interview answers are recorded in a [pepsi-wizard] section at the end of the file (secrets — the smarthost password, merchant access token and the generated SRS key — are not stored there); re-running –wizard imports them as the new defaults.

Because the main configuration is written world-readable (mode 0644, so the unprivileged stage accounts can read it), the wizard does not leave secrets in it. Every secret it would otherwise write inline — the SRS SECRET, the smarthost/LMTP PASSWORDs, the merchant MERCHANT_ACCESS_TOKEN and the pepsi-httpd RESUME_AUTHORIZATION_TOKEN — is moved into a restricted secrets.d/*.secret fragment next to the configuration and referenced from it with an @inline-secret@ directive (see pepsi.conf(5)). The generated ones (the SRS key, the /resume token) are read back from the existing fragment and reused, so re-running the wizard never silently rotates them (which would invalidate SRS senders already in flight or the deployed payment webhook).

85.1.39.1.5.2. Secret prompts

The secrets you type — the smarthost and LMTP passwords, the merchant access token — are never echoed and never written into [pepsi-wizard]. On a re-run they are read back from their secrets.d fragment, so the prompt shows [***] and Enter keeps the stored secret:

Smarthost password [***]:

Three answers are possible:

Enter

Keep what is stored. When the fragment exists but this process may not read it (it belongs to the service account and the wizard is running unprivileged), the fragment is left completely untouched and the configuration keeps pointing at it — the secret is preserved without the wizard ever seeing it.

a value

Replace the stored secret with what you typed.

Enter twice

Leave the secret unset for now. This is offered only when nothing is stored and only on a terminal; the wizard then writes the configuration without that option, prints the section, option and file to add it to, and leaves a commented line in the fragment naming it. The services will not work until you fill it in and run pepsi-setup run.

For a smarthost that authenticates, the wizard then offers to check the credentials by opening a session to the relay and authenticating — exactly what a delivery does, minus the message — so a mistyped password surfaces while you are still there to correct it rather than as mail that quietly stops flowing:

Check these credentials against smtp.relay.example:587 now [Y/n]:

If the relay refuses them the wizard offers to take the password again; if it cannot be reached at all (firewall, DNS, the host being down) it says so and does not push you into retyping a password that may be perfectly good. The check is skipped entirely when stdin is not a terminal: an unattended run must not depend on the network being up. Each directive is written after the last option of the section it feeds, because a directive ends its section for the parser (see pepsi.conf(5)). The fragments are secured (mode and ownership) as soon as they are written and again by run; see the run command below. The wizard then re-loads the file it has just written and validates that, so a configuration it cannot itself parse is never left behind. After writing, the wizard offers to run the full setup (run) immediately.

If the target file already exists and contains a [pepsi-wizard] section, its answers are imported as defaults. If it exists without one, the wizard warns that it cannot import previous answers and asks for confirmation before overwriting; –force overwrites without prompting.

Note

The anti-spam paywall requires its message templates (e.g. payment-request.en.body) to be installed under [pepsi] TEMPLATE_DIR (default ${DATADIR}/templates, i.e. /usr/share/pepsi/templates for a --prefix=/usr installation); make install places them there. Because the wizard validates against the live environment, enabling the paywall before the templates are installed reports a clear error rather than writing a configuration that would fail at run time.

85.1.39.1.5.3. Answering without a terminal

–answers FILE runs the whole interview from a JSON object of question identifier to value, asking nothing. It is not a second implementation of the wizard: the same interview() runs, with every prompt short-circuited to the supplied answer (or, where there is none, to the wizard’s own default, exactly as pressing Enter would). The branch structure, the per-field validation and the rendering are therefore the interactive ones.

The identifiers are what pepsi-setup questions prints, and they are the same keys the generated [pepsi-wizard] section round-trips — so a configuration a previous run wrote is itself a valid answer file once that section is turned into JSON. The expert options (–expert, and whatever an MTA migration found) share that namespace and are accepted here too, validated by the same check the --expert prompt applies; the browser interview deliberately refuses them, since an option nothing asks about is not one a web form should set. An unknown identifier is an error naming the key rather than a silently dropped line, because an answer that is quietly ignored looks exactly like one that was accepted and had no effect.

Secrets may appear in the file (SMARTHOST_PASSWORD, LMTP_PASSWORD, MERCHANT_ACCESS_TOKEN); they are moved into secrets.d fragments like any other, so the file itself should be treated as a credential and deleted afterwards. A secret already stored in a fragment is carried forward when the answer file does not mention it, which is what makes a scripted re-run non-destructive.

Example:

cat > answers.json <<'EOF'
{
  "DIRECTION": "both",
  "HOSTNAME": "mx.example.com",
  "DOMAINS": "example.com",
  "POSTMASTER": "postmaster@example.com",
  "PUBLIC_IPS": "203.0.113.7",
  "LOCAL_METHODS": "maildir",
  "MTA_STS": "yes"
}
EOF
pepsi-setup --wizard --answers answers.json -c /etc/pepsi/pepsi.conf

85.1.39.1.6. Migrating from another MTA

Nobody installs a mail server on an empty machine. When –wizard finds no configuration at the target path, it looks for the mail server the host runs today — Postfix, Exim, Sendmail, qmail or Stalwart — and offers to import its settings as the interview’s answer defaults. If several are configured it presents a numbered menu; –import MTA picks one without asking and –no-import skips the search entirely. Auto-detection is skipped when stdin is not a terminal, so a scripted –wizard run keeps producing the configuration its answers describe rather than one shaped by whatever was left over on the machine.

Nothing is imported silently. Every imported value becomes the [default] on its question, which the operator confirms or corrects, and everything that could not be carried over is collected into a migration report written next to the configuration as import-report.txt (also printed as a one-line summary before the interview starts). The report groups its findings into four categories:

FAILED

A file could not be read or parsed; nothing was imported from it.

UNSUPPORTED

Understood, but Pepsi has no equivalent — a header_checks table, an alias delivering to a command. The entry names what Pepsi offers instead, where something does.

APPROXIMATED

Imported with different semantics; mbox delivery becoming Maildir, a maximal_queue_lifetime rounded to whole hours, a loopback network dropped from MYNETWORKS.

UNKNOWN

A directive the importer does not recognise, and therefore did not migrate. Reported so an unrecognised setting is visible rather than invisible.

An import can never fail the run: an unreadable file, a corrupt line or a directive from a fork nobody has heard of produces a warning, and the rest of the configuration is still imported.

85.1.39.1.6.1. Routing tables

/etc/aliases, /etc/postfix/virtual, Sendmail’s virtusertable, qmail’s .qmail-* files and their equivalents are converted into a Pepsi alias map (see pepsi-stage-aliases(1)) written next to the configuration as aliases, and a submission-identity table (Postfix’s smtpd_sender_login_maps and friends) into username.map (see USERNAME_MAP in pepsi-ingress(1)). An existing file is never overwritten: the converted table lands at aliases.imported instead, for the operator to merge.

Pepsi alias keys carry a domain (local@domain, @domain or a * glob), while /etc/aliases keys are bare local parts, so the wizard asks how to qualify them (–alias-style for the import command):

per-domain

One line per alias and accepted domain — exact, and the default.

wildcard

One line per alias with a domain glob (postmaster@*) — compact, but it also matches domains added later.

primary

Qualify with the first accepted domain only; the others are reported as not aliased.

Entries Pepsi cannot express — a delivery to a command (|/usr/bin/…), to a file, or an unreadable :include: — are written into the converted map as inert # UNCONVERTED comments next to a report entry explaining why, so nothing disappears without a trace. The generated map is checked with the same strict syntax check run applies to a hand-written one.

85.1.39.1.6.2. Per-server coverage

Postfix

/etc/postfix/main.cf (with $parameter interpolation and continuation lines) and master.cf. Identity comes from myhostname/mydomain, the accepted domains from mydestination, virtual_alias_domains, virtual_mailbox_domains and relay_domains; the smarthost from relayhost with its transport from smtp_tls_security_level / smtp_tls_wrappermode and its credentials from smtp_sasl_password_maps. Local delivery is read from home_mailbox, mailbox_transport/virtual_transport (an lmtp:unix: target becomes Pepsi’s LMTP stage) and mail_spool_directory; submission SASL from smtpd_sasl_type = dovecot and smtpd_sasl_path; the enabled services in master.cf decide the direction. alias_maps/alias_database and virtual_alias_maps become the alias map (virtual entries win, as they do in Postfix), and smtpd_sender_login_maps becomes username.map, inverted to Pepsi’s login-keyed form. message_size_limit, mynetworks, recipient_delimiter, maximal_queue_lifetime, smtp_helo_name and the smtpd_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 -o overrides in master.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.conf is the richest and most reliable source and takes precedence: dc_eximconfig_configtype gives the direction and whether there is a smarthost, dc_other_hostnames the domains, dc_smarthost the relay, dc_relay_nets the trusted networks and dc_localdelivery the mailbox format. The main section of exim4.conf.template / exim.conf / conf.d/main/* (everything before the first begin) adds primary_hostname, qualify_domain, domainlist local_domains, hostlist relay_from_hosts, message_size_limit, smtp_accept_max and the tls_certificate pair. Exim’s list syntax is handled properly — doubled separators, <; overrides, .include, +named references, and .ifdef evaluated against the macros actually defined. /etc/aliases becomes the alias map, /etc/email-addresses the submission-identity map, and /etc/exim4/passwd.client the 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 begin block is reported once, naming what replaces it in Pepsi (the stage graph for routers and transports, listener MYNETWORKS/SASL plus the whitelist and anti-spam stages for ACLs, SRS and the alias map for rewriting). Two consequences: because the retry ladder lives in begin retry, MAX_LIFETIME is never guessed, and because the transport holds the TLS and AUTH policy, the smarthost’s transport security is assumed rather than read. A tls_certificate containing 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.cf is used when no .mc exists, and then only its reliably-readable lines (Dj the canonical name, DS the smart host, Cw/Fw the local host names, DZ the version and the O options). 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 the FEATUREs that matter — use_cw_file, virtusertable, authinfo, local_lmtp, plussed_users, local_procmail, nullclient. The tables local-host-names, virtusertable, aliases and authinfo (the smarthost credentials, with the mechanism from its M: field) are read; postmaster is resolved through the alias chain to a real address.

Reported rather than migrated: MASQUERADE_AS and 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, and virtusertable right-hand sides Pepsi cannot express (error:, %1).

qmail

The control directory (/var/qmail/control, /etc/qmail or /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. me gives the hostname; locals, rcpthosts, morercpthosts and virtualdomains the domains; the :relay:port catch-all in smtproutes the smarthost (with credentials when the AUTH patches’ extended form is used); defaultdelivery (or the qmail-start argument in rc) the mailbox format; databytes, queuelifetime, helohost and a servercert.pem the expert options.

The alias table is the .qmail-* files in qmail’s alias directory: .qmail-foo-bar becomes foo-bar, : decodes back to ., .qmail-default becomes a catch-all and .qmail-foo-default a 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 ~/.qmail files are deliberately not walked (expensive and privacy-sensitive; pepsi-stage-dot-forward is the runtime equivalent), and no .cdb is ever opened — the text users/assign is read instead. badmailfrom/badrcptto, spfbehavior, percenthack, qmqpservers and tlsclients are reported with their Pepsi counterparts.

Stalwart

config.toml under /opt/stalwart-mail/etc, /opt/stalwart/etc, /etc/stalwart or /etc/stalwart-mail (a directory of .toml fragments is read too, and !include directives are followed). server.hostname and the server.listener.* ports give the identity and direction, queue.outbound.next-hop / queue.strategy.route and the remote.* table the smarthost (a protocol = "lmtp" hop becomes local LMTP delivery instead), certificate.* the TLS paths, session.data.limits.size and the queue expiry the expert options. A memory-type directory (or a principals array) yields accounts: each extra address becomes an alias to the account’s primary one, and the logins become username.map. Secrets written as %{file:…}% are resolved; %{env:…}% cannot be and is reported.

Stalwart’s own expressions ([{if = …}, {else = …}]) are never evaluated — only a plain value is honoured, anything else is reported rather than guessed at. The important case to know about: recent Stalwart versions keep the operational configuration in their data store, not in the TOML. When the file looks like a bootstrap stub, the report says so prominently, names the store, and tells you to export the configuration from Stalwart’s admin interface — there is nothing on disk for Pepsi to read. IMAP, JMAP, POP3, ManageSieve, Sieve, spam-filter and reporting settings are reported as unsupported with a pointer to the Pepsi (or Dovecot) equivalent.

85.1.39.1.6.3. Credentials

Smarthost relay credentials are read from the source MTA’s own password file (Postfix’s smtp_sasl_password_maps, Exim’s passwd.client, Sendmail’s authinfo, Stalwart’s TOML) so the operator does not have to recover a password they may no longer have. The report always names the file a secret was read from. Imported passwords follow the same path as typed ones: the wizard moves them out of the world-readable configuration into secrets.d/*.secret fragments referenced with @inline-secret@.

85.1.39.1.6.4. What is not migrated

Mail already queued in the old server is not migrated — let the queue drain, or flush it, before switching the MX over. DKIM keys are not migrated either; run generates fresh ones and prints the DNS records to publish. If the old MTA is still listening on port 25 when the wizard runs, the report says so: pepsi-ingress cannot bind the port until it is stopped and disabled.

85.1.39.1.7. Expert options

Between the questions the interview asks and the full option set of pepsi.conf(5) there is a tier of ordinary Pepsi options nobody should have to answer to get a working server: the message size limit, the recipient delimiter, trusted networks, the queue lifetime, the DKIM selector.

–expert SPEC makes the wizard ask about them, in a final interview step. SPEC is a comma- or space-separated list of group words and/or option names:

high

The options an administrator plausibly tunes: MAX_MESSAGE_SIZE, MYNETWORKS, RECIPIENT_DELIMITER, MAX_LIFETIME, DMARC_ENFORCE and MAILBOX_QUOTA (deliberately in this group rather than insane: a site that delivers locally will want a default quota and should not have to know the option’s name).

insane

Everything in the registry, adding HELO_NAME, TLS_CERT, TLS_KEY, DNS_TIMEOUT, MAX_CONNECTIONS, MAX_OPEN_SOCKETS, DKIM_SELECTOR, KEY_DIR, MAILBOX_OVER_QUOTA and CRYPTO_ALLOW_DOWNGRADE.

An option name

Ask about exactly this one, whatever its group: --expert=MAX_MESSAGE_SIZE,RECIPIENT_DELIMITER. Combines with a group word: --expert=high,DKIM_SELECTOR.

all is accepted as a synonym for insane, and none (or off) clears a group word given earlier in the same SPEC. An unrecognised selector is an error listing the valid names, not a silently empty selection.

--expert=help prints the list and exits.

Each option is emitted into the section that actually reads it — MYNETWORKS onto the port-25 listener (never a submission listener), MAX_MESSAGE_SIZE onto [pepsi-ingress], HELO_NAME onto the smarthost’s route ([pepsi-stage-relay-to-smarthost-mta-smarthost]) — and is recorded in [pepsi-wizard] so a later re-run keeps it. Leaving an answer empty removes the option and restores Pepsi’s own default.

RECIPIENT_DELIMITER is the one worth spelling out: it is written into the global [pepsi] section rather than onto the local-delivery stages, because pepsi-ingress (which has no stage section of its own) must read the same value to decide whose mailbox quota a sub-addressed recipient belongs to, and the stages’ own Locality parser falls back to [pepsi]. MAILBOX_QUOTA, MAILBOX_OVER_QUOTA and CRYPTO_ALLOW_DOWNGRADE land there too.

–expert only controls which options are asked about. A value found by an MTA import is applied whether or not it was asked for: if Pepsi has the option, a migrated setting is never dropped for want of a question.

85.1.39.1.8. Commands

import [MTA] [–root DIR] [–out DIR] [–alias-style STYLE]

Report what would be migrated from an existing MTA, writing nothing. With no MTA the installed one is detected (and, if several are, they are listed and one must be named). The output is the same migration report –wizard writes, plus the alias map that would be generated and the wizard answers the import would pre-fill; secrets are shown as a placeholder, never printed.

This command needs no Pepsi configuration — it is meant to be run before there is one, to see what a migration would produce.

–root DIR

Read the MTA’s configuration from under DIR instead of /. Useful for inspecting a backup or another machine’s /etc copied into place.

–out DIR

Write the report and the converted map files into DIR (an existing file is diverted to <name>.imported rather than overwritten).

–alias-style STYLE

How bare alias names are qualified: per-domain (default), wildcard or primary; see Migrating from another MTA.

run [-r | –reset]

Perform the full setup (validate, provision TLS certificates, install schema, generate keys, print DNS records). This is also the default action when no subcommand is given. It also provisions the proof-of-origin secret whenever [pepsi-origin] has neither a SECRET nor a SECRET_FILE and is not switched off with ENABLED = no — proof of origin is on by default, so the section need not be present: a random secret is written to a secrets.d/pepsi-origin.secret fragment (owned by the pepsi account, mode 0640) and referenced from the main configuration with an @inline-secret@ directive, keeping it out of that world-readable file; a re-run reuses the existing fragment rather than rotating the key. It is skipped, with a warning, when [pepsi-ingress] HOSTNAME is unset (the name is bound into the Pepsi-Origin MAC) or when the path of the configuration file to update is unknown.

On the same principle it generates the [pepsi-crypto] key-encryption key when there is none — but only after the schema is installed, and only when the database confirms that no wrapped private key exists yet. A missing fragment beside stored keys is a lost key, not a new install, and minting a fresh one would silently make every stored private key unopenable; so both “keys exist” and “I could not check” refuse loudly instead, naming restore-the-fragment as the fix.

Before anything else, run secures any secret fragments beside the configuration — the secrets.d/*.secret files the –wizard externalizes secrets into (see below) — giving each 0640 mode and handing it to the account that must read it: secrets.d/pepsi.secret (smarthost/LMTP passwords, the merchant access token) to pepsi, secrets.d/pepsi-httpd.secret (the RESUME_AUTHORIZATION_TOKEN) to pepsi-httpd, secrets.d/pepsi-ingress.secret (the SRS SECRET) to pepsi-ingress, secrets.d/pepsi-crypto.secret (the [pepsi-crypto] KEY_WRAP_SECRET, current and retired) to pepsi-crypto, and secrets.d/pepsi-secure-link.secret (the [pepsi-secure-link] PEPPER) and secrets.d/pepsi-list.secret (the [pepsi-list] UNSUBSCRIBE_SECRET) to pepsi-httpd with group pepsi — the two fragments with two readers, since the portal serves what the stage sealed and the web endpoint verifies the unsubscribe token the delivery stage computed. The SRS secret is owned by pepsi-ingress because ingress reverse-decodes SRS return addresses; the SRS stage runs as pepsi and reads it through the shared pepsi-ingress group (the Debian package makes pepsi a 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.service into line with [pepsi] SHARE_TELEMETRY: systemctl enable --now when telemetry is on. When it is off the unit is left as the operator left it — never started, and no longer stopped: a running daemon is dormant (no socket, nothing submitted), and keeping it is what lets the browser console offer to switch telemetry on. Either way run then sends the telemetry_changed notification, so a running daemon follows the answer at once — turning telemetry off takes effect immediately. pepsi.target does not start that unit — nothing in the target Wants it, because a unit the target pulled in would run whatever the answer was, though the unit is PartOf= the target and so stops with the pipeline. It happens here, at the end, because the SYSTEM_ID is generated earlier in this same run; with telemetry on and no valid identifier the unit is armed anyway (the daemon stays dormant and says why) and the reason is logged. All of it is best-effort: a host with no systemd, no unit installed or no root is told the single systemctl command instead. See pepsi-telemetry-client(1).

-r, –reset

Drop all existing pepsi objects before re-creating them. DANGEROUS: all stored messages are irrecoverably lost. Signing keys on disk are not affected. Only a database built by a development version from a different copy of an unreleased patch ever needs this; an older release’s schema is upgraded in place (see schema).

schema [–backup-dir DIR] [–if-installed]

Install or upgrade the database schema and re-apply the role grants, and nothing else: no configuration validation beyond [pepsi-postgres], no certificates, no keys, no DNS. It therefore works with any configuration file that names the database – /etc/pepsi/pepsi.conf on a mail host, /etc/pepsi-telemetry/pepsi-telemetry.conf on a telemetry collector – and is what a package upgrade runs, before the services restart.

The installer records the SHA-256 of every SQL file it applies, and the release it came from, in pepsi.schema_file; every Pepsi program compares that record with the hashes compiled into it when it connects, and refuses to run (exit status 78) against a schema that is older, newer or built from different files. schema brings an older schema up to this release. It refuses, before changing anything and with exit status 78, a schema that is newer than this release (downgrades are not supported: older stored functions installed over a newer schema would break it), one built from a different copy of a patch file, and one with no record at all (built by a development version before the record existed; such a database has to be drained and re-created with run –reset). It also refuses SQL files in SQL_DIR that are not the ones this binary was built with – a stale directory after a partial upgrade. Concurrent installers serialise on an advisory lock.

–backup-dir DIR

Before an upgrade changes anything, save the schema with pg_dump(1) (custom format; the pepsi and _v schemas and the extensions, which the archive’s trigram index needs) as DIR/pepsi-OLD-VERSION-UTC-TIME.dump, mode 0600, run as the schema owner. Taken only when there is something to upgrade. If the dump fails, nothing is upgraded. Old dumps are never deleted. Restore one with pg_restore(1) into an empty database.

–if-installed

Do nothing when the database has no Pepsi schema yet. A new installation is set up with run; this is for maintainer scripts, which must not install a schema nobody asked for.

check

Query live DNS and report, for each authoritative domain, whether the records actually published match what run would generate. It checks the DKIM selector records (one well-formed key record of the right k= type whose p= public key matches the local key; see Prints DNS records under run), the SPF record (the v=spf1 record lists every configured PUBLIC_IP), the _dmarc TXT record (exactly one, and strictly valid — see Prints DNS records under run above), and the _mta-sts TXT record (present and a parseable v=STSv1 record with an id= tag — the id value is operator-chosen and is not compared, though its syntax is: RFC 8461 §3.1 allows 1 to 32 letters and digits). Each record is reported as [ok], [MISSING], [MISMATCH], [INVALID] or [lookup failed]; every non-[ok] line is followed by an indented → line naming the exact record to publish and pointing back to run for the authoritative value. The command makes no changes, and its findings never set the exit status: it exits 0 however many records are missing or wrong, so read its output rather than its exit status. (Only a configuration that does not validate, or a resolver that cannot be built at all, makes it exit non-zero — it has nothing to report then.)

[INVALID] is the verdict for a record that is published but that receivers discard, so that the domain is unprotected while looking configured. Its remedy differs from the others’: the record is repaired in place rather than replaced with the value run prints. Besides the DKIM findings described below, three checks produce it — a DMARC record that does not parse (or a second one at the same name), more than one v=spf1 record (RFC 7208 §4.5 makes that a permanent error, so no SPF policy applies rather than the first one winning), and more than one v=STSv1 record or one whose id= is outside the allowed syntax. A v=spf1 record ending in +all is reported as a [MISMATCH]: it is valid, it passes the “are my addresses listed?” test, and it authorises every host on the Internet to send as the domain, defeating the DKIM and DMARC records published beside it.

Every record is checked twice: in the system resolver’s answer, and at each of the zone’s authoritative nameservers asked directly. A record the resolver returns correctly but some nameserver does not (a stale secondary, a lame or unreachable server) is a [MISMATCH] naming the servers, with a remedy pointing at the zone’s distribution rather than at the record. A DKIM record with the wrong or missing k=, a misplaced v=, an h=/s= restriction excluding what Pepsi signs, or a second key record at the same name is [INVALID]; t=y is a [MISMATCH]. When Ed25519 signing is on, a closing note: explains why Google reports that selector as fail.

A record may also be [ok] and carry a note — a p=none DMARC policy is valid, is the right place to start, and enforces nothing, so the report says so rather than leaving a domain to sit in monitoring mode indefinitely.

Beyond the TXT records, for every domain an MTA-STS policy is served for it runs the two MTA-STS cross-checks described under run — the policy’s mx set against the live MX records, and a fetch of https://mta-sts.<domain>/.well-known/mta-sts.txt compared with the configured policy — and ends with a note giving the MTA_STS_MX line that would make them agree. For the [pepsi-srs] SRS_DOMAIN it adds the deliverability check described under The SRS domain can receive its own bounces.

questions

Print the setup interview to stdout as JSON: the ordered steps, and for each question its identifier, kind (text, bool, choice, list, secret, path, port, domain, integer, address, url), prompt, help text, default, whether it is required, the configuration option it maps to when it maps to exactly one, and the condition under which it is asked at all.

This is the model both front-ends are described by — the terminal interview and the browser one pepsi-httpd serves at /api/v1/setup/questions — and it is the schema an –answers file is written against. It needs no configuration — it is what you read before you have one.

apply [–once] [–idle SECS] | apply –clear

Perform the privileged setup work the browser interface has asked for. Run as root. See The applier’s trust model below before deploying this.

On a Debian system the systemd units that run this on demand are in the separate pepsi-httpd-admin package; removing it leaves this subcommand working from a root shell while making it unreachable from the web console. See Removing the capability: the pepsi-httpd-admin package below.

It reads intent rows from the pepsi.setup_task table — 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_task NOTIFY channel 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_task without executing any of them, report how many were removed, and exit. Refused together with –once or –idle: this is an administrative reset, not a draining mode.

A task is a durable request to a process running as root and nothing expires it, so a queue that has been sitting there is a hazard rather than a backlog. The applier’s units are a separate package precisely so a deployment can run with nothing draining that queue — and rows can still reach it in that state, for instance from an operator with a root shell. Install the missing package months later and every one of them runs at the first doorbell ring, in the order it was asked for, against a deployment that has moved on. Consent does not keep, so installing pepsi-httpd-admin runs this from its postinst.

Every row goes, not only the pending ones. Only a pending row can ever execute and a running row is orphaned by definition, so deleting just those two would be enough for safety — but a “clear” that left a console full of done and refused rows 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’s setup_task_log progress lines follow it by cascade.

A database with no Pepsi schema in it is reported as an empty queue rather than as an error — it has never held a task — so this is safe to run on a host that has not been set up yet.

Pass a -c FILE for the draining modes: the applier writes that file and its secrets.d fragments, and the path comes from its own command line and never from anything a task can influence. It is not strictly required — with no -c the ordinary default search below applies, and the applier only fails when that finds nothing — but naming the file is the point: the default search consults $XDG_CONFIG_HOME and $HOME, which is not what a root-run applier should be resolving. –clear executes nothing and so needs no such file.

bootstrap [–valid-for SECS] [–admin-url URL]

Mint the one-time credential that creates the first administrator account, and print it with the curl command that uses it.

A fresh install has no account, so nothing can log in to the console that would create one. This prints a bearer token holding the full administrator scope set, valid for SECS (default 3600) and accepted exactly once — enough for a single POST /api/v1/accounts. It is consumed on presentation by an update only one racing request can win, so a token read by two people admits one of them.

The scope list is not a choice: POST /api/v1/accounts refuses to issue a scope the caller does not hold outright, so a token holding setup:write alone could not create an administrator — and, since the token is consumed before the handler refuses, the attempt would spend it. The printed curl asks for exactly the scopes the token was minted with, rendered from the same list, so the two cannot drift.

Single-use because of where it is printed: a token on a terminal is in a scrollback buffer and a token in the journal is readable by whoever can read the journal. Only its digest is stored, so it cannot be recovered — run the command again if you lose it. Running it again also revokes any outstanding bootstrap token.

A local administrator over the ADMIN = yes UNIX-socket listener is identified by SO_PEERCRED and 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 Graphviz dot graph. Each stage is one node, labelled with its configuration section name (e.g. stage-init) and the PROGRAM it runs with the pepsi-stage- prefix stripped (e.g. arc). A solid edge follows each stage’s NEXT_STAGE and a dashed red bounce edge its BOUNCE_STAGE. The command only reads the configuration (no DNS, schema or filesystem changes); pipe it through dot to render, for example:

pepsi-setup -c /etc/pepsi/pepsi.conf visualize | dot -Tpng -o pipeline.png

85.1.39.1.9. The applier’s trust model

pepsi-setup apply is the one component in Pepsi that runs as root on behalf of an HTTP request.

85.1.39.1.9.1. Why the console does not act

pepsi-httpd drops privileges before it accepts a single connection, and must never be able to regain them: it parses attacker-supplied input for a living. It therefore cannot write /etc/pepsi/pepsi.conf, hand a secrets.d fragment to the account that reads it, run certbot, create a database role or generate key material — all of which setup has to do.

So it does not try. It writes a row saying what should be true, and the applier decides how. Root stays off the network entirely: it is reached through a database table, not through a socket that speaks a protocol.

85.1.39.1.9.2. The closed task list

There are ten task kinds and there will not be a general one. An arbitrary-command kind would be a root shell over HTTP wearing a task name.

write-config

Render the configuration from an answer set — the same answers the interview collects — validate it exactly as pepsi-setup run would, 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-secret

Store one credential in the secrets.d fragment its reader owns, preserving every other secret in that fragment. It refuses any [section] OPTION pair 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-certificate

Obtain 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-schema

Install 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 --reset on a console.

provision-roles

Create the database login roles and re-apply their grants.

generate-keys

Generate 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-preflight

Run read-only environment probes and record the report. Which probes is an allow-list (ports, dnssec, dns-records), so “run a preflight” cannot become “run this”.

generate-identity

Generate a server-managed OpenPGP key (an MTA key) for one address (parameters address and an optional boolean vks, which defaults to [pepsi-keys] VKS_PUBLISH and requests a key-server upload). The address must be at a [pepsi-ingress] ACCEPTED_DOMAINS domain and a KEY_WRAP_SECRET must be configured. Always performed on request, even next to the user’s own key or a revoked key. The applier does the work as the pepsi-crypto role (root becomes that account for the connection, as pepsi-keys(1) does), because only that role may write the private column.

register-client-key

Register the user’s own public key (an MUA key, custody = client) for one address (parameters address and key, an OpenPGP public key as text, at most 64 KiB). The address must be served, the key must be one OpenPGP certificate one of whose User IDs names the address, and a fingerprint the address already has, retired or not, is not registered again. It goes through the applier although no private material is involved: a key registered as the user’s own becomes the address’s public face and verifies signatures as the user’s, so a compromised web tier must not be able to plant one.

revoke-identity

Revoke one local identity (parameters address, identity_id and an optional one-line reason of at most 1024 bytes, recorded as the revocation reason). The identity must belong to address. The retirement rule is pepsi-keys identity revoke’s: a signing-only key’s private half is destroyed, an encryption key’s is kept so mail already encrypted to it stays readable. Performed as the pepsi-crypto role, because destroying a private half writes the private column. Revoking an identity that is already revoked changes nothing and succeeds; the result says which happened.

reset-otp

Remove one address’s second factor (parameter address), which is also how a second factor locked by ten wrong codes is unlocked; its owner then enrols again. Performed as the pepsi-crypto role, the only one granted pepsi.otp_key. Removing one that does not exist succeeds and says so.

These four act on one address’s keys rather than on the deployment, and are authorised differently (see The two gates).

The owner’s second factor. generate-identity, register-client-key and revoke-identity accept an optional otp parameter (six digits). For a task admitted on own:<address> alone – the address’s owner asking, not an operator holding keys:write – the applier checks it against the address’s second factor (see pepsi-keys(1), otp) before doing anything: an address without one needs no code, and a missing, wrong, replayed or locked code fails the task with the reason. The attempt is recorded (a wrong code counts towards the lock) and a code is used up even if the task then fails.

85.1.39.1.9.3. What is deliberately absent

There is no restart, reload or shutdown task, and there will not be one — the same decision that keeps service control out of the console. The cost is real: a configuration change that needs a component restarted ends with the operator restarting it. Where possible, components pick changes up by themselves instead (the config_changed notification does this for the database configuration overlay).

85.1.39.1.9.4. The two gates

A task is acted on only when both of these hold:

  • its recorded scopes contain setup:write — the authorisation the administrative API established for the principal that asked. For the four key kinds, generate-identity, register-client-key, revoke-identity and reset-otp, the scope required is instead keys:write, or – for all but reset-otp, which is the operator’s alone – own:<address> for exactly the address named in the task’s parameters: a user managing their own key is not configuring the server, and setup:write alone does not suffice. The address is taken from the parsed parameters, never from the scope list, so a principal confined to one address cannot name another; and

  • its written_by is the pepsi-config database role — proof that the row reached the table through the one path we control. That column is forced from current_user by a BEFORE INSERT trigger, so it is a fact about the connection rather than a claim in the payload, and there is no spelling of the INSERT that gets a forged value past it.

pepsi-setup run enforces the second half against PostgreSQL on every run: only pepsi-config may INSERT into setup_task; pepsi-httpd may only SELECT (it enqueues through the separate [pepsi-admin] CONFIG_DB connection); and no account that processes mail may touch the table at all. A stage worker parses hostile mail for a living and must not be able to put work in front of a root process. That is verified, not asserted: a mismatch is a hard error naming the role and the privilege.

85.1.39.1.9.5. What it cannot promise

The applier never saw the HTTP request, so it cannot re-authenticate the principal. scopes is a claim recorded by the administrative surface; what the applier verifies is that the claim was recorded by the only role permitted to record it. A compromise of pepsi-httpd together with its configuration database credential therefore reaches the closed task list above. That is the residual risk, and it is why the list is closed and small rather than convenient.

85.1.39.1.9.6. Removing the capability: the pepsi-httpd-admin package

The applier is only ever started by systemd socket activation on the doorbell pepsi-httpd rings. Those two unit files — pepsi-setup-apply.socket and pepsi-setup-apply.service — are therefore the whole of the path from an HTTP request to a change under /etc/pepsi, and Debian ships them in their own binary package, pepsi-httpd-admin.

pepsi Recommends it, so a default installation has it and the browser console works as documented. An operator who administers this deployment from a terminal removes it:

apt remove pepsi-httpd-admin

and pepsi stays installed. A source install has the same lever as make install INSTALL_ADMIN_UNITS=no.

What that removes is the mechanism, not a button. With the units gone nothing starts the applier, so a setup_task row is inert however it got there: pepsi-httpd detects the missing doorbell and serves its configuration and setup pages read-only, refusing the matching API mutations with 503 setup_applier_unavailable — see pepsi-httpd(1), The privileged applier, and doing without it.

pepsi-setup itself stays in the pepsi package and is unaffected. pepsi-setup run, pepsi-setup --wizard, pepsi-setup check and even pepsi-setup apply --once still work — root running the program is root acting, which is not what this restricts. What it removes is the web tier’s ability to cause that to happen.

Note

Package removal deliberately does not revoke the database grant that lets the pepsi-config role INSERT into pepsi.setup_task, for three reasons: a maintainer script would need a reachable cluster and a superuser credential it does not have, so a hardening step could turn into a removal failure; the grant is not the boundary that matters, because a row nothing drains is inert and the applier’s two gates apply regardless; and a later reinstall would come back with the console broken in a way whose error message points at the socket rather than at the grant.

A site that wants the belt as well as the braces can do it by hand, and pepsi-setup run (which re-applies the grants) puts it back:

REVOKE INSERT ON pepsi.setup_task FROM "pepsi-config";

85.1.39.1.9.7. Auditability

Every task is a durable row: who asked, what was requested, when it started and finished, what it produced, and what went wrong. Every admission, refusal, success and failure is additionally written to the audit log (setup.task.requested, setup.task.refused, setup.task.done, setup.task.failed), which is append-only for every component that processes mail. Progress lines are streamed into setup_task_log as the work proceeds, so a certbot run or a schema install can be watched without the applier holding an HTTP connection.

The applier’s writes are announced, so nobody watching a task has to ask the database again and again. Two triggers in procedures.sql notify with the task id as payload: setup_task_progress for every setup_task_log line, and setup_task_done when a task becomes done, failed or refused – whichever writer makes that transition, the applier settling a task or reaping one an earlier applier left running. The applier itself issues no pg_notify; pepsi-httpd(1) listens on both channels and wakes the requests waiting on that task (GET /api/v1/setup/tasks/{id}?wait= and the console’s task page). The setup_task channel that wakes the applier is a different one and is not fired by either.

85.1.39.1.10. Global Options

These global options precede the subcommand (a trailing flag is rejected).

-c FILE, –config FILE

Read the configuration from FILE instead of searching the default locations (see FILES). pepsi-setup also rewrites this file in place when it auto-fills TLS certificate paths, the reverse-proxy socket, the proof-of-origin secret, the key-encryption-key reference, the telemetry system id and the probed [pepsi] MAILBOX_FS_QUOTA. When -c is omitted it resolves the same default location the config was loaded from — normally /etc/pepsi/pepsi.conf — and rewrites that, so a bare pepsi-setup run behaves like pepsi-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 certbot to obtain missing TLS certificates. The certbot path layout is still filled into the configuration when needed; a certificate that is then absent is deferred, not fatal — the host is listed in the end-of-run summary with an actionable message, and the rest of the run (schema, keys, DNS) proceeds. Use this when certificates are managed by other means, and note that a successful exit therefore does not assert that every configured certificate is present.

–no-reverse-proxy

Do not auto-integrate with an existing front HTTP server. pepsi-httpd then binds port 443 directly instead of being switched to a UNIX socket behind a reverse proxy (see step 3 of the Description). Use this when no other web server occupies 80/443, or when you wire the reverse proxy by hand.

-y, –yes-to-all

Assume “yes” to every interactive prompt setup may raise (currently the offer to install the Dovecot drop-in, step 4), so a run never blocks waiting on input — useful in scripts and non-interactive deployments. Mutually exclusive with -n.

-n, –no-to-all

Assume “no” to every interactive prompt: setup takes no offered action and instead prints what it would have done (e.g. the Dovecot drop-in to deploy by hand), then defers it. Also makes the run fully non-interactive.

–wizard

Run the interactive configuration wizard (see Wizard). The configuration is written to the -c path, or to /etc/pepsi/pepsi.conf when no -c is given. Unlike the other modes, this does not require an existing configuration file. It takes no subcommand (pepsi-setup --wizard run is refused): at the end it offers to run the full setup itself. The options below that say “with –wizard” are refused without it, except --expert=help.

–force

With –wizard, overwrite an existing configuration file that has no importable [pepsi-wizard] answers without prompting for confirmation.

–expert SPEC

With –wizard, also ask about the deeper Pepsi options the interview normally decides for you. SPEC is a group (high, insane), a comma-separated list of option names, or both; --expert=help lists them and exits. See Expert options.

–answers FILE

With –wizard, answer every question from the JSON object in FILE instead of asking: the keys are the question identifiers pepsi-setup questions prints (plus the expert option names), and a question the file does not answer takes the wizard’s default, as pressing Enter would. An unknown key or an answer of the wrong shape is an error. Nothing is read from stdin, and every other prompt takes its default too — in particular the closing “Run setup now” offer, so run pepsi-setup run afterwards. See Answering without a terminal.

–import MTA

With –wizard, import defaults from this existing MTA (postfix, exim, sendmail, qmail, stalwart) instead of offering a menu of what was detected. Unlike auto-detection this also applies when a configuration file already exists, and it works on a non-interactive stdin. See Migrating from another MTA.

–no-import

With –wizard, do not look for an existing MTA configuration at all.

–import-root DIR

With –wizard, read the MTA to import from under DIR instead of / — a backup, or another machine’s configuration copied into place. The same option the import sub-command spells –root.

-L LOGLEVEL, –log LOGLEVEL

Set the logging verbosity. LOGLEVEL is one of error, warn, info, debug or trace (default: info).

-v, –verbose

Show log messages from all sources, including third-party libraries.

-h, –help

Print a usage summary and exit.

-V, –version

Print the version and exit.

85.1.39.1.11. Exit Status

0

Successful completion.

1

An error occurred: a malformed configuration file, an invalid domain name or PUBLIC_IP entry, a configuration file that could not be written, a failed database connection, or a key that could not be written. The reason is written to the log.

A TLS certificate that is missing and could not be obtained is not in this list: it is deferred and reported in the end-of-run summary (step 4), and the run still succeeds.

85.1.39.1.12. Files

When –config is not given, the first existing file from the following list is used:

  • $XDG_CONFIG_HOME/pepsi.conf

  • $HOME/.config/pepsi.conf

  • /etc/pepsi/pepsi.conf

  • /etc/pepsi.conf

Note the last two: every Pepsi program prefers the canonical /etc/pepsi/pepsi.conf over the bare /etc/pepsi.conf, because the packaging, the systemd units and @inline-secret@ all name the subdirectory — a stale copy directly in /etc must not shadow the file the running services read. When more than one of these exists, pepsi-setup logs which file it is using and which ones are being ignored.

Generated key material is stored below the configured KEY_DIR (default /var/pepsi/keys), one directory per domain containing dkim.rsa.key and dkim.ed25519.key.

Beside the configuration file, –wizard writes aliases (the alias map, converted from the previous MTA’s routing tables when one was imported), username.map (the submission-identity map), secrets.d/*.secret (the externalized secrets) and, after a migration, import-report.txt. A file that already exists is never overwritten by an import: the converted content is written to <name>.imported instead.

85.1.39.1.13. Examples

Bootstrap a fresh deployment and capture the DNS records to publish:

pepsi-setup -c /etc/pepsi/pepsi.conf run > pepsi-dns.zone

Re-run after an upgrade that ships new migrations (keys and DNS are unchanged unless a new domain was added):

pepsi-setup -c /etc/pepsi/pepsi.conf run

Validate the configuration and reset the database from scratch:

pepsi-setup -c /etc/pepsi/pepsi.conf run --reset

After publishing (or changing) DNS, verify what is actually live against what Pepsi expects:

pepsi-setup -c /etc/pepsi/pepsi.conf check

Render the stage pipeline to a PNG to review how messages are routed:

pepsi-setup -c /etc/pepsi/pepsi.conf visualize | dot -Tpng -o pipeline.png

Before migrating, see what would be taken from the mail server this host runs today (nothing is written):

pepsi-setup import

Migrate from Postfix, answering the deeper questions too:

pepsi-setup --wizard --import postfix --expert=high

Inspect what a migration would produce from another machine’s /etc, saved under /mnt/oldhost:

pepsi-setup import postfix --root /mnt/oldhost --out /tmp/migration

85.1.39.1.14. See Also

pepsi-config(1), pepsi.conf(5), pepsi-ingress(1), pepsi-stage-relay-to-smarthost(1), pepsi-keys(1), pepsi-keydisc(1)

85.1.39.1.15. Bugs

Report bugs to the Pepsi issue tracker.