3. 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.
3.1. Prerequisites¶
PostgreSQL — one database, into which Pepsi installs the single
pepsischema. All components connect to the same database. (The Debian packageDependsonpostgresqland 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.
3.2. Installing Pepsi¶
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).
Additional prerequisites
A recent Rust toolchain (the workspace uses edition 2024; build with a stable
cargothat supports it).The vendored GNU Taler Rust submodule, fetched before the first build:
git submodule update --init --recursive
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, andpepsi-stage-encryptandpepsi-stage-decrypt(both setuidpepsi-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$PATHsymlink,pepsi-detect-language, runs the offline diagnostic tool out of it) andpepsi-telemetry(which ships in its own package and runs on a separate host).
The full set of binaries is:
pepsi-ingress— inbound SMTP serverpepsi-dispatch— stage dispatcherpepsi-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 stagespepsi-setup— provisioning/bootstrappepsi-queue— queue inspection/repairpepsi-status— pipeline health summarypepsi-sendmail— local submission (installed as/usr/sbin/sendmailby 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
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-binBuilds the release (unified) binary and installs it to
$(DESTDIR)$(PREFIX)/libexec/pepsi/pepsi, then creates one$(DESTDIR)$(PREFIX)/binsymlink 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)/binexecutables. Because each symlink still lives in$(PREFIX)/bin(on$PATH), the runtimeDATADIRderivation below is unaffected.install-dataCopies the SQL files from
pepsi-setup/db/to$(DESTDIR)$(DATADIR)/sql(whereDATADIR = $(PREFIX)/share/pepsi).pepsi-setupreads these when installing the schema; the defaultSQL_DIRis${DATADIR}/sql.install-templatesCopies the message templates (
<name>.<lang>.body— the anti-spam payment request, the vacation notice, the bounce bodies) to$(DESTDIR)$(TEMPLATEDIR), which defaults to$(DATADIR)/templates. The binaries’[pepsi] TEMPLATE_DIRdefault is${DATADIR}/templatesand resolves through the same runtime derivation, so the two ends agree under any prefix without the configuration naming a path.install-configInstalls
pepsi.conf.sampleand, only if no live config exists yet, apepsi.confunder$(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 <install-prefix>/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.
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--systemwith/usr/sbin/nologinand home/var/pepsiand a private group of the same name, plus the two gating groupspepsi-maildirandpepsi-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
postgressuperuser):createuser pepsi-owner pepsi pepsi-ingress pepsi-httpd createdb -O pepsi-owner pepsi
Run
make installasroot(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-setupcreates/var/pepsi/keysfor you; create the smarthost-secret directories only if you configure a smarthost using them.
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.
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-tokengating groups (the same identities the source install makes by hand);creates the
/var/pepsiruntime directories —keys,tokens,token-refresh,tls,krb5— and/etc/pepsi/secretswith the ownership and modes documented under Directories and secrets below;applies the three privileged-binary bits via
dpkg-statoverride(pepsi-helper-maildir-writersetuid root, the maildir and smarthost relay stages setgid); andbest-effort creates the four PostgreSQL login roles and the
pepsidatabase (owned bypepsi-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. Debian packages describes each package and that lever in
full.
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.
3.3. 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
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 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.
3.3.1. 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
pepsi-stage-milter. Filters whose job Pepsi already does
(opendkim, openarc, opendmarc, SPF daemons, postsrsd) are named
and skipped rather than offered.
3.4. Provisioning the database, keys and DNS¶
With a configuration file in place (see 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 pepsi-setup for the full provisioning workflow.
3.5. 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.
3.5.1. 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).
3.5.2. 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 Debian packages). Three steps:
# 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.
3.5.3. 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=<seq> streams what the applier is
doing, line by line.
3.5.4. 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.
3.5.5. 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.
3.6. 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).
3.7. 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 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 The split as a hardening lever.
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.
3.7.1. 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.
Account |
Runs |
Purpose / access |
|---|---|---|
|
|
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
|
|
|
HTTP/HTTPS server (MTA-STS policy + |
|
|
The pipeline’s working identity. Reads the DKIM keys (via group
|
|
|
Not a runtime service. Owns the |
|
|
Only present when a smarthost uses |
|
|
Custodian of private end-to-end key material. Owns
|
|
|
Account the setuid whitelist CLI assumes so ordinary users can maintain
their own sender whitelists. Its database role is granted
|
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.
3.7.3. 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:
Binary |
Mode / owner |
Why |
|---|---|---|
|
|
Setuid root so it can write into any local user’s mailbox; group-locked
so only |
|
|
Setgid so the |
|
|
Setgid so the |
Every other binary is installed unprivileged.
3.7.4. 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.
Path |
Owner / mode |
Contents |
|---|---|---|
|
|
Service home. |
|
|
Per-domain DKIM private keys: written by |
|
|
OAuth access-token files: written by the refresher, read by the smarthost
relay via group |
|
|
Private rotated refresh tokens; never group-readable. |
|
|
The |
|
|
Mutual-TLS client cert + key for the smarthost relay
( |
|
|
Kerberos credential cache for |
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.
3.8. 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-setupapplies 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 byrun --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).