70.1.30. pepsi-setup

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

Manual section:

1

70.1.30.1.1. Name

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

70.1.30.1.2. Synopsis

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

pepsi-setup [GLOBAL-OPTIONS] check

pepsi-setup [GLOBAL-OPTIONS] visualize

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

pepsi-setup [GLOBAL-OPTIONS] questions

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

pepsi-setup [GLOBAL-OPTIONS] apply –clear

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

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

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

70.1.30.1.3. Description

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

  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. If anything is wrong it reports the problem and exits non-zero without changing anything. The operator-edited map files (below) are deliberately not part of this step, so an unrelated map typo can never block the run. If the pipeline uses 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).

    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.

    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=www-data), 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, as before). 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 (www-data) 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)). They are added to the listener’s own certificate rather than given a [pepsi-httpd-cert-*] section of their own, since that certificate is the fallback served whenever SNI matches nothing — so one certificate covers both the SNI hit and the fallback. Note that an already-issued certificate is never re-issued, so a deployment that predates this (or that adds a served domain) must extend its certificate by hand with certbot certonly --cert-name <host> --expand -d .

    Then, for each such host whose certificate is not already on disk, it runs certbot certonly --standalone (HTTP-01 over TCP/80, registered without an email) to obtain it. This step is skipped for any listener whose paths are set explicitly. A certificate that is missing and cannot be obtained is deferred rather than fatal: setup continues (installs the schema, generates keys, prints DNS records) and lists every deferred certificate in a summary at the very end, with the concrete next step and a reminder to re-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.

  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. Three roles are deliberately narrower than the rest:

    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.ingress, 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 the point of the exercise. 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, 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 is the [pepsi-ingress] HOSTNAME), so pepsi-setup prints only the _mta-sts TXT record (with the policy id) and a reminder to point mta-sts.<domain> at the server. 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.

    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, which is why this one is worth the words.

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

    • Nothing published. v=DMARC1; p=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.)

    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 grothoff.org announced in EHLO while its address reverse-resolves to zkey.eu) passes every other check here and still has its mail refused;

    • a PTR that could not be looked up at all — an undelegated reverse zone or unreachable DNS. Correctness could not be established, and an address with no usable 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.

    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 OAuth token refreshing. When any smarthost MTA uses AUTH = oauth, pepsi-setup validates the corresponding [pepsi-helper-token-refresh-<name>] secret sections it can read (it runs 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.

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

70.1.30.1.4. Privileges and ownership

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

  • Database objects are created as the pepsi-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.

70.1.30.1.5. Wizard

For a first deployment, –wizard lowers the entry barrier: instead of editing the configuration by hand, it runs a short interactive interview and writes a complete, already-validated configuration file.

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

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

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

It then asks for the upstream smarthost (optional when local delivery handles the rest, required otherwise).

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

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

What it does ask, as a yes/no question defaulting to no, is whether to additionally trust loopback:

TRUST_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.

Earlier versions asked this as a four-way LOCAL_SUBMISSION question (none/loopback/sendmail/all) that bundled the two together. An existing answer file is migrated: loopback and all become TRUST_LOOPBACK = yes, the other two no, and the socket is served regardless.

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

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

The way those listeners bind their ports depends on the host. When the system is managed by systemd (detected by the presence of /run/systemd/system), the wizard writes socket-activated listeners (SERVE = systemd with a FD_INDEX per listener, counted from 0 in section order within each owning service) so the privileged ports are bound by .socket units and inherited by the unprivileged service; otherwise it falls back to direct binds (SERVE = tcp with BIND_TO / PORT). With socket activation you must install the matching .socket units (a pepsi-ingress.socket listing ListenStream= for the enabled ingress listeners in FD_INDEX order, and a pepsi-httpd.socket for port 443); the wizard prints a reminder after writing.

Two transport-security questions follow. On the inbound path it asks whether to serve an MTA-STS policy (default yes): when enabled it sets MTA_STS_MODE = enforce and adds a [pepsi-httpd-cert-*] section for each accepted domain’s mta-sts.<domain> policy host (served over HTTPS by pepsi-httpd), whose certificate pepsi-setup provisions via certbot like any other — you must point each mta-sts.<domain> name’s DNS at the server for the policy to be reachable. It then asks whether to send SMTP TLS Reports (TLSRPT, RFC 8460; default yes), which emits the [pepsi-tlsrpt] section. Declining both — and not enabling the paywall — produces a minimal host with no HTTP server at all: the pepsi-httpd sections are emitted only when something needs them (the MTA-STS policy host(s) or the paywall /resume webhook).

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

70.1.30.1.5.1. Mail filters (milters) found on this host

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

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

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

  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).

70.1.30.1.5.2. Secret prompts

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

Smarthost password [***]:

Three answers are possible:

Enter

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

a value

Replace the stored secret with what you typed.

Enter twice

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

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

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

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

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

Note

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

70.1.30.1.5.3. Answering without a terminal

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

The identifiers are what pepsi-setup questions prints, and they are the same keys the generated [pepsi-wizard] section round-trips — so a configuration a previous run wrote is itself a valid answer file once that section is turned into JSON. An unknown identifier is an error naming the key rather than a silently dropped line, because an answer that is quietly ignored looks exactly like one that was accepted and had no effect.

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

Example:

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

70.1.30.1.6. Migrating from another MTA

Nobody installs a mail server on an empty machine. When –wizard finds no configuration at the target path, it looks for the mail server the host runs todayPostfix, 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.

70.1.30.1.6.1. Routing tables

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

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

per-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.

70.1.30.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). One consequence worth knowing: 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.

70.1.30.1.6.3. Credentials

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

70.1.30.1.6.4. What is not migrated

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

70.1.30.1.7. Expert options

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

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

high

The options an administrator plausibly tunes: MAX_MESSAGE_SIZE, MYNETWORKS, RECIPIENT_DELIMITER, MAX_LIFETIME, DMARC_ENFORCE.

insane

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

An option name

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

--expert=help prints the list and exits.

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

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

70.1.30.1.8. Commands

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

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

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

–root DIR

Read the MTA’s configuration from under DIR instead of /. Useful for inspecting a backup or another machine’s /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 when [pepsi-origin] is present without one: 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.

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, and secrets.d/pepsi-crypto.secret (the [pepsi-crypto] KEY_WRAP_SECRET, current and retired) to pepsi-crypto. 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, disable --now when it is not — so an operator who turns it off has the daemon stopped rather than merely told to stop. That unit is the one Pepsi daemon not pulled in by pepsi.target, because a unit the target starts would run whatever the answer was. It happens here, at the end, because the daemon refuses to start without the SYSTEM_ID generated earlier in this same run; with telemetry on and no valid identifier the unit is left alone (starting it would only give Restart=on-failure something to loop on) and the reason is logged. All of it is best-effort: a host with no systemd, no unit installed or no root is told the 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.

check

Query live DNS and report, for each authoritative domain, whether the records actually published match what run would generate. It checks the DKIM selector records (the published p= public key matches the local key), the SPF record (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 fix: (or ) line naming the exact record to publish and pointing back to run for the authoritative value. The command makes no changes and always exits 0; it is a diagnostic report, so read its output rather than its exit status.

[INVALID] is the verdict for a record that is published but that receivers discard, so that the domain is unprotected while looking configured. Its remedy differs from the others’: the record is repaired in place rather than replaced with the value run prints. Three checks produce it — a DMARC record that does not parse (or a second one at the same name), more than 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.

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.

The MTA-STS policy file (served over HTTPS at https://mta-sts.<domain>/.well-known/mta-sts.txt) is not fetched by this check; only DNS records are verified.

questions

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

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

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

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

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

It reads intent rows from the pepsi.setup_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 (an operator with a root shell, or an older release that did not refuse every path through the web tier). Install the missing package months later and every one of them runs at the first doorbell ring, in the order it was asked for, against a deployment that has moved on. Consent does not keep, so installing pepsi-httpd-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.

A -c FILE is required for the draining modes: the applier writes that file and its secrets.d fragments, and the path deliberately comes from its own command line rather than from anything a task can influence. –clear executes nothing and so needs no such file.

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

Mint the one-time credential that creates the first administrator account, and print it with the 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 setup:write and nothing else, 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.

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

70.1.30.1.9. The applier’s trust model

pepsi-setup apply is the one component in Pepsi that runs as root on behalf of an HTTP request. This section says exactly what it will and will not do, and why the web console cannot act on its own.

70.1.30.1.9.1. Why the console does not act

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

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

70.1.30.1.9.2. The closed task list

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

write-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”.

70.1.30.1.9.3. What is deliberately absent

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

70.1.30.1.9.4. The two gates

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

  • its recorded scopes contain setup:write — the authorisation the administrative API established for the principal that asked; 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.

70.1.30.1.9.5. What it cannot promise

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

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

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

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

apt remove pepsi-httpd-admin

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

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

pepsi-setup itself stays in the pepsi package and is unaffected. pepsi-setup run, pepsi-setup --wizard, pepsi-setup check and even pepsi-setup apply --once still work — root running the program is root acting, which was never the thing being restricted. What is gone is the web tier’s ability to cause that to happen.

Note

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

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

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

70.1.30.1.9.7. Auditability

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

70.1.30.1.10. Global Options

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

-c FILE, –config FILE

Read the configuration from FILE instead of searching the default locations (see FILES). pepsi-setup also rewrites this file in place when it auto-fills TLS certificate paths, the reverse-proxy socket, the proof-of-origin secret and the telemetry system id. When -c is omitted it resolves the same default location the config was loaded from — normally /etc/pepsi/pepsi.conf — and rewrites that, so a 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, but if a certificate is then absent pepsi-setup fails rather than trying to obtain it. Use this when certificates are managed by other means.

–no-reverse-proxy

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

-y, –yes-to-all

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

-n, –no-to-all

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

–wizard

Run the interactive configuration wizard (see Wizard). The configuration is written to the -c path, or to /etc/pepsi/pepsi.conf when no -c is given. Unlike the other modes, this does not require an existing configuration file.

–force

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

–expert SPEC

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

–import MTA

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

–no-import

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

–import-root DIR

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

-L LOGLEVEL, –log LOGLEVEL

Set the logging verbosity. LOGLEVEL is one of error, warn, info, 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.

70.1.30.1.11. Exit Status

0

Successful completion.

1

An error occurred: a malformed configuration file, an invalid domain name or PUBLIC_IP entry, a TLS certificate that is missing and could not be obtained (see step 2), a failed database connection, or a key that could not be written. The reason is written to the log.

70.1.30.1.12. Files

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

  • $XDG_CONFIG_HOME/pepsi.conf

  • $HOME/.config/pepsi.conf

  • /etc/pepsi/pepsi.conf

  • /etc/pepsi/pepsi.conf

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

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

70.1.30.1.13. Examples

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

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

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

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

Validate the configuration and reset the database from scratch:

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

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

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

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

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

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

pepsi-setup import

Migrate from Postfix, answering the deeper questions too:

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

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

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

70.1.30.1.14. See Also

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

70.1.30.1.15. Bugs

Report bugs to the Pepsi issue tracker.