.. This file is part of PEPSI. Copyright (C) 2026 Pepsi contributors PEPSI is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. ============ Installation ============ Pepsi can be installed two ways. Building **from source** out of the Cargo workspace gives you the binaries and leaves it to you to create the service accounts, set the privileged bits and lay out the runtime directories. The prebuilt **Debian package** does all of that for you — its ``postinst`` creates the accounts, groups, directories, privileged-binary overrides and the database — so only the site-specific bootstrap (editing the config and running ``pepsi-setup``) is left to do. The notebook below covers each path: pick the tab for the method you are using. Everything after it — provisioning the schema/keys/DNS, running the services, the privilege model and the schema lifecycle — is the **same for both**. Prerequisites ============= * **PostgreSQL** — one database, into which Pepsi installs the single ``pepsi`` schema. All components connect to the same database. (The Debian package ``Depends`` on ``postgresql`` and pulls it in for you.) * For real mail flow: public DNS you control for each served domain (to publish DKIM, SPF and MTA-STS records), and the ability to bind the privileged SMTP port 25 (and 465/587 for submission). A development build can run entirely on unprivileged ports and a throwaway database. Installing Pepsi ================ .. tab-set:: .. tab-item:: From source Build the workspace, place the binaries and SQL with ``make install``, and create the system accounts and privileges yourself (the same ones the package would create — see *Users, groups and privileges* below). .. rubric:: Additional prerequisites * A recent **Rust** toolchain (the workspace uses edition 2024; build with a stable ``cargo`` that supports it). * The vendored **GNU Taler Rust** submodule, fetched before the first build:: git submodule update --init --recursive .. rubric:: Building Pepsi is a Cargo workspace whose **root package** (``pepsi``) owns every binary under ``src/bin/``; the ``pepsi-*`` member crates are libraries the thin binaries call into. A development build produces one executable per program:: cargo build --workspace A **release** build instead links most programs into a single multi-call binary, ``pepsi``, which selects the program to run from the name it is invoked under (``argv[0]``). This is selected by the ``unibin`` Cargo feature (which the ``make build`` target enables for you); the default ``multibin`` feature keeps the per-program binaries used for development and the test suite:: make build # == cargo build --release --no-default-features --features unibin Either way the per-program names are what you run; the release layout simply installs them as symlinks to the one binary (see *Installing*). Some programs are **not** folded in and stay standalone; the authoritative list is the Makefile's ``STANDALONE_BINARIES``. Two reasons put a program there: * it carries its own **setuid or setgid bit**, which one shared binary cannot do per program — the local-delivery, ``~/.forward``, wallet and smarthost helpers and their calling stages, ``pepsi-whitelist``, and ``pepsi-stage-encrypt`` and ``pepsi-stage-decrypt`` (both setuid ``pepsi-crypto``, so they can reach the private key material); * it is deliberately separable — ``pepsi-setup``, ``pepsi-config``, ``pepsi-stage-detect-language`` (its language models are large; that binary is itself multi-call, and a second ``$PATH`` symlink, ``pepsi-detect-language``, runs the offline diagnostic tool out of it) and ``pepsi-telemetry`` (which ships in its own package and runs on a separate host). The full set of binaries is: * ``pepsi-ingress`` — inbound SMTP server * ``pepsi-dispatch`` — stage dispatcher * ``pepsi-stage-arc``, ``pepsi-stage-srs``, ``pepsi-stage-encrypt``, ``pepsi-stage-decrypt``, ``pepsi-stage-dkim-sign``, ``pepsi-stage-bounce``, ``pepsi-stage-relay-to-internet``, ``pepsi-stage-relay-to-smarthost``, ``pepsi-stage-discard`` — the stages * ``pepsi-setup`` — provisioning/bootstrap * ``pepsi-queue`` — queue inspection/repair * ``pepsi-status`` — pipeline health summary * ``pepsi-sendmail`` — local submission (installed as ``/usr/sbin/sendmail`` by the Debian package) The four quality gates the project keeps green are:: RUSTFLAGS="-D warnings" cargo build --workspace cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all -- --check cargo test --workspace .. rubric:: Installing A bare build only produces *binaries*; Pepsi also ships SQL migrations and a sample configuration that must be placed on disk. The ``Makefile`` wraps both — run it as ``root`` so it can also apply the three privileged-binary bits (it prints the exact ``chown``/``chmod`` to run by hand otherwise):: make install PREFIX=/usr/local SYSCONFDIR=/etc DESTDIR= Better still, run ``./configure`` first and let it resolve the directory variables into ``config.mk`` (which the ``Makefile`` reads before its own defaults, so what ``configure`` decided wins). Every directory derives from ``--prefix``, so naming a prefix and nothing else relocates the whole installation — binaries, SQL, ``config.d`` defaults and message templates — below it:: ./configure --prefix=/opt/pepsi # templates at /opt/pepsi/share/pepsi/templates make && make install ``--sysconfdir`` is the one exception, because a configuration file has to be where the *system* looks for it: it is ``/etc`` when no ``--prefix`` was given (and when ``--prefix=/usr`` was, since ``/usr/etc`` exists on no system), and ``$(PREFIX)/etc`` otherwise. Pass ``--sysconfdir`` explicitly to override either way. This performs the following steps: ``install-bin`` Builds the release (unified) binary and installs it to ``$(DESTDIR)$(PREFIX)/libexec/pepsi/pepsi``, then creates one ``$(DESTDIR)$(PREFIX)/bin`` symlink per folded program name pointing at it (e.g. ``pepsi-ingress -> ../libexec/pepsi/pepsi``). The six standalone programs are installed as their own ``$(DESTDIR)$(PREFIX)/bin`` executables. Because each symlink still lives in ``$(PREFIX)/bin`` (on ``$PATH``), the runtime ``DATADIR`` derivation below is unaffected. ``install-data`` Copies the SQL files from ``pepsi-setup/db/`` to ``$(DESTDIR)$(DATADIR)/sql`` (where ``DATADIR = $(PREFIX)/share/pepsi``). ``pepsi-setup`` reads these when installing the schema; the default ``SQL_DIR`` is ``${DATADIR}/sql``. ``install-templates`` Copies the message templates (``..body`` — the anti-spam payment request, the vacation notice, the bounce bodies) to ``$(DESTDIR)$(TEMPLATEDIR)``, which defaults to ``$(DATADIR)/templates``. The binaries' ``[pepsi] TEMPLATE_DIR`` default is ``${DATADIR}/templates`` and resolves through the same runtime derivation, so the two ends agree under any prefix without the configuration naming a path. ``install-config`` Installs ``pepsi.conf.sample`` and, only if no live config exists yet, a ``pepsi.conf`` under ``$(DESTDIR)$(SYSCONFDIR)/pepsi/`` (an existing config is preserved). Override ``PREFIX``, ``SYSCONFDIR`` and ``DESTDIR`` to suit the target system. ``make uninstall`` removes the binaries and SQL but leaves the live ``pepsi.conf`` in place. .. note:: ``DATADIR`` must match what the binaries compute at runtime: ``taler-common`` derives ``${DATADIR}`` as ``/share/pepsi`` from the binary's own location. When running from a source checkout (not installed), set ``[pepsi-postgres] SQL_DIR`` explicitly so ``pepsi-setup`` finds the migrations. .. rubric:: Preparing the system The Debian package's ``postinst`` does the steps below automatically; on a source install you do them yourself, using the **same** account, group, mode and directory layout documented under *Users, groups and privileges* and *Directories and secrets* below. Create the accounts and groups **before** ``make install`` (so it can apply the privileged bits), and the database roles **before** ``pepsi-setup`` (which installs the schema): #. Create the five system accounts (``pepsi``, ``pepsi-ingress``, ``pepsi-httpd``, ``pepsi-owner``, ``pepsi-helper-token-refresh``), each ``--system`` with ``/usr/sbin/nologin`` and home ``/var/pepsi`` and a private group of the same name, plus the two gating groups ``pepsi-maildir`` and ``pepsi-token``:: for u in pepsi pepsi-ingress pepsi-httpd pepsi-owner \ pepsi-helper-token-refresh; do addgroup --system "$u" adduser --system --home /var/pepsi --no-create-home \ --ingroup "$u" --shell /usr/sbin/nologin \ --disabled-password "$u" done addgroup --system pepsi-maildir addgroup --system pepsi-token #. Create the four PostgreSQL login roles and the database (peer auth, as the ``postgres`` superuser):: createuser pepsi-owner pepsi pepsi-ingress pepsi-httpd createdb -O pepsi-owner pepsi #. Run ``make install`` as ``root`` (above) so the three setuid/setgid bits are applied; otherwise apply them by hand per *Privileged binaries* below. #. Create the runtime directories with the ownership and modes listed under *Directories and secrets*. ``pepsi-setup`` creates ``/var/pepsi/keys`` for you; create the smarthost-secret directories only if you configure a smarthost using them. .. tab-item:: Debian package The package is the much simpler path: it ships the binaries (installed to ``/usr/bin``), the SQL schema, the message templates, the man pages, the GNU info manual and **socket-activated systemd units**, and its ``postinst`` provisions the whole system for you. .. rubric:: Installing the package Install the ``.deb`` with ``apt`` so its dependencies (including ``postgresql``) are pulled in:: apt install ./pepsi_*.deb On ``configure`` the ``postinst`` automatically: * creates the five service accounts and the ``pepsi-maildir`` / ``pepsi-token`` gating groups (the same identities the source install makes by hand); * creates the ``/var/pepsi`` runtime directories — ``keys``, ``tokens``, ``token-refresh``, ``tls``, ``krb5`` — and ``/etc/pepsi/secrets`` with the ownership and modes documented under *Directories and secrets* below; * applies the three privileged-binary bits via ``dpkg-statoverride`` (``pepsi-helper-maildir-writer`` setuid root, the maildir and smarthost relay stages setgid); and * best-effort creates the four PostgreSQL login roles and the ``pepsi`` database (owned by ``pepsi-owner``) when a local cluster is reachable — printing the roles to create by hand if it is not. The service is shipped **disabled**: the package installs but does not start Pepsi, leaving the site-specific bootstrap to you. .. note:: ``pepsi`` is one of **four** binary packages built from this source: the language-detection stage and the telemetry collector are split out for size and deployment reasons, and ``pepsi-httpd-admin`` — which ``pepsi`` ``Recommends``, so ``apt`` installs it by default — carries the only mechanism by which the browser console can cause a privileged change on this host. Removing that one package is a supported and meaningful hardening step for a deployment administered from a terminal. :doc:`packages` describes each package and that lever in full. .. rubric:: After the package is installed The package places the configuration at ``/etc/pepsi/pepsi.conf``. Edit it, then run the bootstrap exactly as in the shared steps below — for example:: # 1. edit /etc/pepsi/pepsi.conf (HOSTNAME, ACCEPTED_DOMAINS, domains, TLS) # 2. install the schema + DKIM keys and print the DNS records: pepsi-setup --no-certbot run # or: pepsi-setup --wizard # 3. publish the printed DNS records, then start the pipeline: systemctl enable --now pepsi.target Because the units are socket-activated, ``pepsi.target`` brings up ``pepsi-ingress``, ``pepsi-httpd`` and ``pepsi-dispatch`` together; you do not invoke the ``serve`` subcommands by hand. Migrating from an existing mail server ====================================== If this host already runs Postfix, Exim, Sendmail, qmail or Stalwart, you do not have to re-derive its configuration by hand. With no ``pepsi.conf`` present, ``pepsi-setup --wizard`` detects what is installed and offers to import it: the hostname, accepted domains, smarthost (with the relay credentials read from that server's own password file), local-delivery method, submission SASL socket, trusted networks, message size limit, queue lifetime and TLS certificate paths all arrive as the interview's pre-filled answers, which you then confirm or correct one by one. To see what a migration would produce *before* running the wizard — nothing is written:: pepsi-setup import Routing tables are converted too. ``/etc/aliases``, ``/etc/postfix/virtual``, Sendmail's ``virtusertable``, qmail's ``.qmail-*`` files and their equivalents become a Pepsi alias map beside the configuration (see :doc:`programs/pepsi-stage-aliases`), with bare alias names qualified in the style you choose. Since Pepsi alias keys must carry a domain, ``postmaster: root`` becomes ``postmaster@example.org root@example.org`` for each accepted domain by default. Everything that does **not** carry over is written to ``import-report.txt`` beside the configuration, split into what failed to parse, what Pepsi has no equivalent for (``header_checks``, an alias delivering to a command), what was approximated (mbox delivery becoming Maildir) and what the importer did not recognise. An import never aborts setup — an unreadable file or an unknown directive produces a report entry, not a failure. Two things are deliberately not migrated: **queued mail** (let the old server's queue drain before switching the MX over) and **DKIM keys** (``pepsi-setup run`` generates fresh ones and prints the records to publish). If the old MTA still holds port 25 when the wizard runs, the report says so — stop and disable it before starting ``pepsi-ingress``. See :doc:`programs/pepsi-setup` for the per-server detail, the ``--alias-style`` choices and the ``--expert`` options that expose the settings the interview does not otherwise ask about. Mail filters already on the host -------------------------------- Independently of any migration, the wizard **scans the host for milter daemons** — ``clamav-milter``, ``rspamd``, ``spamass-milter``, ``milter-greylist``, ``milter-regex``, ``mimedefang``, ``amavisd-milter`` — and offers each one it finds as a stage. Finding one means three things in turn: the package is installed, a socket can be read from its own configuration (or its unit, or the paths its distribution ships), and **a real milter option negotiation completes against that socket**. Nothing is offered on the strength of an installed package alone, and the negotiation is also how the generated ``ALLOW_ACTIONS`` comes to grant exactly what that filter asks for rather than a guess. Accepted filters are placed by what they do — policy and greylisting, then virus, then spam, then general-purpose frameworks — after decryption and before the whitelist and paywall gates. Because Pepsi filters *after* accepting a message, a filter's ``REJECT`` is routed to a generated discard stage rather than bounced, so forged senders get no backscatter; see :doc:`programs/pepsi-stage-milter`. Filters whose job Pepsi already does (``opendkim``, ``openarc``, ``opendmarc``, SPF daemons, ``postsrsd``) are named and skipped rather than offered. Provisioning the database, keys and DNS ======================================= With a configuration file in place (see :doc:`configuration`), run the bootstrap tool once:: pepsi-setup -c /etc/pepsi/pepsi.conf run > pepsi-dns.zone This **validates** the whole configuration, **installs** the ``pepsi`` schema, **generates** per-domain RSA-2048 and Ed25519 DKIM keys under ``KEY_DIR`` (only if absent), and **prints** the DNS records to publish (DKIM public keys, an SPF policy from the configured ``PUBLIC_IP`` addresses, and an MTA-STS record). The operation is idempotent and may be re-run after upgrades. ``run --reset`` drops and recreates the schema (destroying all queued mail; keys on disk are kept). Publish the printed DNS records, then verify what is live against what Pepsi expects:: pepsi-setup -c /etc/pepsi/pepsi.conf check See :doc:`programs/pepsi-setup` for the full provisioning workflow. Setting up in a browser ======================= Everything above can also be done from a browser, against the same code. The terminal path is not going away and is still the shortest route on a machine you have a shell on; the browser path exists for the ones you do not, and for operators who would rather see the DNS records as a red/green checklist than as a zone file. The split, and why ------------------ Setup is privileged: it writes ``/etc/pepsi/pepsi.conf``, hands each ``secrets.d`` fragment to the single account that reads it, runs certbot, creates database roles and generates key material — all as root. ``pepsi-httpd``, which serves the interface, deliberately drops privileges before it accepts a connection and must never regain them. So it does not act. It writes an *intent* row saying what should be true, and a separate root program — ``pepsi-setup apply``, started on demand and gone again when it has nothing to do — decides how, does it, and records what happened. Root is never on the network. **Read "The applier's trust model" in :doc:`programs/pepsi-setup` before enabling this**: it states exactly what the applier will and will not do, including the things it deliberately cannot (there is no restart or shutdown task, and ``install-schema`` refuses ``reset``). Enabling it ----------- The Debian packages ship a minimal but complete configuration, an administrative listener on a UNIX socket, and — in ``pepsi-httpd-admin``, which ``pepsi`` ``Recommends`` and ``apt`` therefore installs by default — the applier's socket unit (see :doc:`packages`). Three steps: .. code-block:: console # systemctl enable --now pepsi-httpd.socket pepsi-setup-apply.socket # pepsi-setup -c /etc/pepsi/pepsi.conf bootstrap The second prints a one-time token and the ``curl`` command that turns it into the first administrator account. On the machine itself you can skip it entirely: a member of the ``pepsi-admin`` group (and root) is identified by ``SO_PEERCRED`` over ``/run/pepsi/admin.sock`` and needs no credential at all. One more setting is needed before anything privileged can be *asked for*: ``[pepsi-admin] CONFIG_DB`` must name a connection that authenticates as the ``pepsi-config`` database role. That is not an oversight to work around — it is the boundary. Only that role may enqueue setup work, and ``pepsi-httpd``'s own account deliberately is not it, so that a compromise of the web tier is not a compromise of the pipeline definition. Until it is set the setup endpoints answer ``503`` and say so. Driving it ---------- The interview is data. ``GET /api/v1/setup/questions`` returns the ordered steps and, for each question, its shape, default, help text and the condition under which it is asked — the same model ``pepsi-setup questions`` prints and the same one the terminal wizard is described by, asserted equal by the test suite. Answers are staged with ``PUT /api/v1/setup/answers`` into draft rows that no running process can see, so an interrupted session cannot half-configure anything. ``POST /api/v1/setup/tasks`` then asks for the privileged half — writing the configuration, obtaining certificates, installing the schema, generating keys — and ``GET /api/v1/setup/tasks/{id}?since=`` streams what the applier is doing, line by line. What will always need a text editor ----------------------------------- Some settings are read from the configuration file only, and no interface will change that: ``[pepsi]``, ``[pepsi-postgres]``, ``[paths]``, and the HTTP(S) and ingress **listener** sections. They have to work before a database connection exists, or they are a security boundary — a database that could move a listening socket or relax the crypto policy would make a database compromise a compromise of the MTA's own TLS and ports. The practical consequence, stated plainly: **adding a submission port, or moving a listener, is a text-editor-and-restart operation.** So is any change the console marks ``restart`` rather than ``hot``: there is deliberately no restart button, because a button that restarts a service on request would hand the web tier's privileges to whoever reaches it. Scripting it ------------ The same answer model drives an unattended install:: pepsi-setup --wizard --answers answers.json -c /etc/pepsi/pepsi.conf ``pepsi-setup questions`` prints the schema that file is written against. This is not a third implementation of the interview: it runs the interactive one with every prompt answered from the file, so the branches and the per-field validation are the same ones a human sees. Running the services ==================== Two long-lived processes need to run continuously: * ``pepsi-ingress serve`` — accepts mail (typically under a service manager, optionally with systemd socket activation for the privileged ports). * ``pepsi-dispatch serve`` — runs **exactly one** instance per system; it is the only process that starts stage programs. The stage programs themselves are spawned by the dispatcher as persistent ``PROGRAM worker`` processes that read message ids on standard input; you do not run them by hand in production (for debugging you can pipe an id to ``worker``, e.g. ``echo 42 | pepsi-stage-srs -c /etc/pepsi/pepsi.conf worker``). Users, groups and privileges ============================= Pepsi never *runs* as ``root``. The long-lived daemons may be **started** as root so they can bind privileged ports and read root-only TLS keys, but each one drops to its own unprivileged service account before serving a single connection; if the drop cannot be completed the process refuses to start rather than risk running as root (see ``pepsi-common::privdrop``). Local delivery — which must write into arbitrary users' mailboxes — is the one operation that needs more than the service user can give, and it is confined to a single narrowly-scoped setuid helper rather than handed to the pipeline at large. Under the shipped systemd units the daemons are never root **at all**: systemd binds the privileged sockets (socket activation) and starts each process as its service account directly. That leaves the TLS keys, which certbot keeps readable by root only — so ``pepsi-setup`` has systemd read those too, writing a ``LoadCredential=`` drop-in per unit: systemd opens each certificate and key as root when it starts the unit and hands the service a private copy under ``$CREDENTIALS_DIRECTORY``, which is where the server looks first. Re-run ``pepsi-setup run`` whenever you add, move or remove a certificate, so the drop-in is regenerated. See :doc:`programs/pepsi-httpd`. The browser setup described above is the one path by which something *other than an operator at a terminal* can cause a root process to run: ``pepsi-httpd`` rings a doorbell socket and systemd starts a short-lived root ``pepsi-setup apply``. That path is deliberately the easiest privilege in Pepsi to take away. Its two systemd units are a **separate Debian package**, ``pepsi-httpd-admin``, whose source-install equivalent is ``make install INSTALL_ADMIN_UNITS=no``; without them nothing drains ``pepsi.setup_task`` and no HTTP request can change anything under ``/etc/pepsi``, while ``pepsi-setup`` on a terminal keeps working exactly as before. Putting the package back does not retroactively act on what was asked for while it was away: installing it empties that queue before its units are armed. See :ref:`packages-hardening`. The accounts and groups below are created automatically by the Debian package's ``postinst``; on a ``make install`` you create them yourself (the same names). PostgreSQL access is by **peer authentication** over the local socket: each service account logs in as a same-named database role, so no passwords are stored anywhere. Service accounts ---------------- These are system accounts (``--system``, ``/usr/sbin/nologin``, home ``/var/pepsi``), each with a private primary group of the same name. The first three are long-lived daemons that drop to their account after binding; the rest are not daemons in the pipeline. .. list-table:: :header-rows: 1 :widths: 24 18 58 * - Account - Runs - Purpose / access * - ``pepsi-ingress`` - ``pepsi-ingress`` - Inbound SMTP server. Socket-activated under systemd (never root, TLS material arrives as a service credential); started by hand it binds 25/465/587 and reads the TLS keys as root, then drops. DB role ``pepsi-ingress``. * - ``pepsi-httpd`` - ``pepsi-httpd`` - HTTP/HTTPS server (MTA-STS policy + ``/metrics``). Socket-activated under systemd, exactly like ``pepsi-ingress``; started by hand it binds 80/443 as root, then drops. DB role ``pepsi-httpd``. * - ``pepsi`` - ``pepsi-dispatch`` **and every stage worker it spawns** - The pipeline's working identity. Reads the DKIM keys (via group ``pepsi``) to sign. DB role ``pepsi`` (owns the queue rows). This is the account that gains the two delivery group-identities below transiently. * - ``pepsi-owner`` - ``pepsi-setup`` (transiently) - **Not a runtime service.** Owns the ``pepsi`` database, schema, tables and functions. When ``pepsi-setup`` runs as root it *temporarily* assumes this identity (``seteuid``) so the objects it creates are owned by the peer-authenticated ``pepsi-owner`` role, then returns to root for the filesystem work (key generation, ownership fix-ups). * - ``pepsi-helper-token-refresh`` - ``pepsi-helper-token-refresh`` service - Only present when a smarthost uses ``AUTH = oauth``. Refreshes OAuth access tokens and writes the token files the smarthost relay reads. Has **no** database role. * - ``pepsi-crypto`` - ``pepsi-keys`` (transiently), and the end-to-end crypto stages - **Custodian of private end-to-end key material.** Owns ``secrets.d/pepsi-crypto.secret``, the key-encryption key that opens ``pepsi.crypto_identity.private_wrapped``, and is the **only** database role granted that column — every other role, ``pepsi`` included, gets a column-level grant that omits it. ``pepsi-keys`` assumes this identity for the duration of a database call and gives it straight back. See :doc:`key-management`. * - ``pepsi-whitelist`` - ``pepsi-whitelist`` (transiently) - Account the setuid whitelist CLI assumes so ordinary users can maintain their own sender whitelists. Its database role is granted ``SELECT``/``INSERT``/``DELETE`` on ``pepsi.whitelist`` and nothing else. The PostgreSQL login roles — ``pepsi-owner``, ``pepsi``, ``pepsi-ingress``, ``pepsi-httpd``, plus the narrow ``pepsi-whitelist`` and ``pepsi-crypto`` roles — are created by the package ``postinst`` (or, on a manual install, by you) before ``pepsi-setup`` installs the schema; ``pepsi-setup`` then grants each what it needs. Groups gating shared resources ------------------------------ Two groups exist purely to *gate* access to privileged binaries and secret files. The ``pepsi`` user is deliberately **not** a permanent member of either: the setgid bit on the relevant stage binary grants the worker the group identity only for the lifetime of that process. ``pepsi-maildir`` Gates the local-delivery helper. ``pepsi-helper-maildir-writer`` is setuid ``root`` and mode ``4750`` (``root:pepsi-maildir``), so only this group may execute it. ``pepsi-stage-relay-to-maildir`` is setgid this group (mode ``2755``, ``root:pepsi-maildir``); when the ``pepsi`` worker execs the stage it gains ``egid=pepsi-maildir``, exactly the right to launch the helper — which then setuids to the *recipient* and writes ``~/Maildir/new/``. The helper is the only code that ever touches a third party's mailbox. ``pepsi-token`` Gates the secret material the smarthost relay reads: OAuth token files, the mutual-TLS client cert/key, and the Kerberos credential cache. ``pepsi-stage-relay-to-smarthost`` is setgid this group (mode ``2755``, ``root:pepsi-token``); the directories holding those secrets are setgid ``pepsi-token`` so files dropped into them inherit the group, and the relay can read them without the ``pepsi`` user being a member. Privileged binaries ------------------- These three bits are set by ``make install`` when run as root (it prints the exact ``chown``/``chmod`` to run by hand otherwise), and by the Debian package via ``dpkg-statoverride``: .. list-table:: :header-rows: 1 :widths: 40 22 38 * - Binary - Mode / owner - Why * - ``pepsi-helper-maildir-writer`` - ``4750 root:pepsi-maildir`` - Setuid root so it can write into any local user's mailbox; group-locked so only ``pepsi-maildir`` may run it. * - ``pepsi-stage-relay-to-maildir`` - ``2755 root:pepsi-maildir`` - Setgid so the ``pepsi`` worker may exec the helper above. * - ``pepsi-stage-relay-to-smarthost`` - ``2755 root:pepsi-token`` - Setgid so the ``pepsi`` worker may read the ``pepsi-token`` secrets. Every other binary is installed unprivileged. Directories and secrets ----------------------- ``pepsi-setup`` and the package create the following under ``/var/pepsi`` (the service home) and ``/etc/pepsi``. The setgid (``2750``) directories cause new files to inherit the directory's group, so token/TLS/Kerberos material is group-readable by the relay without any per-file ``chgrp``. .. list-table:: :header-rows: 1 :widths: 30 24 46 * - Path - Owner / mode - Contents * - ``/var/pepsi`` - ``pepsi-owner:pepsi`` - Service home. * - ``/var/pepsi/keys`` - ``pepsi-owner:pepsi`` ``2750`` - Per-domain DKIM private keys: written by ``pepsi-setup``, read by the ``pepsi`` dispatcher (group ``pepsi``) to sign. * - ``/var/pepsi/tokens`` - ``pepsi-helper-token-refresh:pepsi-token`` ``2750`` - OAuth access-token files: written by the refresher, read by the smarthost relay via group ``pepsi-token``. * - ``/var/pepsi/token-refresh`` - ``pepsi-helper-token-refresh`` ``0700`` - Private rotated **refresh** tokens; never group-readable. * - ``/etc/pepsi/secrets`` - ``root:pepsi-helper-token-refresh`` ``0750`` - The ``@inline-secret@`` token-refresh credentials; only the refresher may read them. * - ``/var/pepsi/tls`` - ``root:pepsi-token`` ``2750`` - Mutual-TLS client cert + key for the smarthost relay (``TLS_CLIENT_CERT`` / ``TLS_CLIENT_KEY``). * - ``/var/pepsi/krb5`` - ``root:pepsi-token`` ``2750`` - Kerberos credential cache for ``AUTH = gssapi`` smarthosts (point ``KRB5CCNAME`` at a ``FILE:`` cache here; an external keytab refresher populates it). The ``/var/pepsi/tokens``, ``/var/pepsi/tls`` and ``/var/pepsi/krb5`` directories (and the ``pepsi-helper-token-refresh`` account) only matter when you configure a smarthost with the corresponding ``AUTH`` mechanism; a default direct-to-MX or local-delivery deployment uses none of them. Database schema lifecycle ========================= The schema is a series of numbered patch files under the ``pepsi`` SQL prefix (in ``pepsi-setup/db/``): * ``pepsi-NNNN.sql`` — the numbered patches. ``pepsi-setup`` applies them in order (``pepsi-0001.sql``, ``pepsi-0002.sql``, …) until one is missing. * ``procedures.sql`` — re-creatable stored functions (``CREATE OR REPLACE``), re-applied on every run. * ``drop.sql`` — ``DROP SCHEMA pepsi CASCADE``, used only by ``run --reset``. * ``versioning.sql`` — the patch-tracking machinery. Applied patches are recorded (by their ``register_patch`` name) so re-runs are idempotent. The patch number is **not** bumped for every schema change. A new ``pepsi-NNNN.sql`` is started only for the **first** schema change made after a release — that is, after a Git tag of the form ``v$MAJ.$MIN.$REV``. Between releases the current top patch file is edited in place: no released deployment carries that not-yet-released schema, so its statements can be added to or rewritten freely. Once a release is tagged that file is frozen, and the next change to touch the schema becomes ``pepsi-(NNNN+1).sql``, whose ``register_patch`` declares the previous patch as its dependency — so databases deployed against the released schema migrate forward incrementally (with ``ALTER``), while a fresh install still applies every patch in order. There has been no release yet, so the entire schema currently lives in the single ``pepsi-0001.sql`` (CREATE-only, no ``ALTER``/``DROP``). The first schema change after the first ``v…`` tag will become ``pepsi-0002.sql`` (keep the filename and its ``register_patch`` name in lockstep).