6. Configuration¶
All Pepsi components read one INI-style configuration file (the format is shared with the GNU Taler tools). This chapter explains the file structure and how the sections compose into a working pipeline. The exhaustive option reference lives in the single man page pepsi.conf(5) and in the per-program chapters under Programs.
6.1. File format¶
A file is a sequence of [SECTION] headers, each followed by
OPTION = VALUE assignments. Section and option names are case-insensitive
(conventionally upper case); # or % begins a comment; a value may be
double-quoted to preserve surrounding whitespace.
Three directives compose multiple files — important for keeping secrets out of the world-readable config:
@inline@ FILEInclude another file, relative to the current one.
@inline-matching@ GLOBInclude every file matching a shell glob.
@inline-secret@ SECTION FILEMerge one section from a mode-restricted file (typically credentials). A directive ends the section it appears in, so write it after that section’s last option; an option below it would belong to no section and the file would not load. A fragment that cannot be read is only a warning, which makes the options it carries look unset;
pepsi-setup rungives each fragment to the service account meant to read it.
Path-valued options undergo $-expansion from the [PATHS] section or the
environment ($VAR, ${VAR}, ${VAR:-default}). Value types are
boolean (YES/NO), number, duration (h/m/s units only —
see the caveat below), path and unix mode (octal).
Warning
duration values are parsed by jiff::SignedDuration, which accepts only
hours/minutes/seconds and below. A calendar unit such as 5 d or 2 weeks
does not parse. For day-or-longer windows write the equivalent in hours
(120 h) or use an integer option where one is provided (e.g.
[pepsi-srs] MAX_AGE_DAYS).
6.1.1. Packaged defaults: config.d¶
Below the operator’s file there is one more layer. Every *.conf in
${DATADIR}/config.d (/usr/share/pepsi/config.d on a packaged install) is
parsed before pepsi.conf, so those files supply defaults that
pepsi.conf overrides option by option. They belong to the package: redefine
what you want in pepsi.conf rather than editing them, or an upgrade will
discard the change.
Two ship today, and they are the two shapes this layer is for:
vacation.confConfiguration that ships as wording rather than as a value — the
[pepsi-vacation-default-message]notices in ten languages that pepsi-stage-vacation falls back to. RedefiningENinpepsi.confleaves the other nine in place.thunderbird.confA default an operator must be able to undo in one step:
[pepsi] CRYPTO_ALLOW_DOWNGRADE = yes, so S/MIME is encrypted as AES-256-CBCEnvelopedData. Thunderbird cannot read the AES-256-GCMAuthEnvelopedDataPepsi’s code default produces, and fails silently when it tries (see Client interoperability). Deleting the file restores authenticated encryption immediately, because the code default was never changed — which is exactly why this lives inconfig.dand not in the source.
6.3. Ingress and listeners¶
[pepsi-ingress] holds message-acceptance policy (HOSTNAME,
ACCEPTED_DOMAINS, MAX_MESSAGE_SIZE, MAX_CONNECTIONS,
DMARC_ENFORCE, DNS settings). Each [pepsi-ingress-listener-<name>] section
binds one socket:
SERVE = tcp(withBIND_TO/PORT),unix(withUNIXPATH) orsystemd(socket activation,FD_INDEX).MODE = plain|starttls|tlsselects the transport security;starttls/tlsrequireTLS_CERTandTLS_KEY.
Declare as many listeners as needed — e.g. an MX listener on 25 with opportunistic STARTTLS and a submissions listener on 465 with implicit TLS.
6.4. The stage pipeline¶
The pipeline is wired entirely from [stage-<name>] sections. The stage
name is an operator-chosen label; the section’s PROGRAM selects the
binary, and NEXT_STAGE/BOUNCE_STAGE connect the graph:
PROGRAM(required) The stage binary (found on
PATHunless absolute). One binary may serve several stages — it reads its options from whichever section the message is currently at.NEXT_STAGE(optional) Where a message advances on success. A terminal stage with no
NEXT_STAGEremoves the delivered message.BOUNCE_STAGE(optional) Where a permanently-failed (or, with
ORIGINATE_SUCCESS_DSN, successfully-delivered) message is routed to generate a DSN — usually apepsi-stage-bouncestage.PARALLELISM/MAX_MESSAGES/QUEUE_LIMIT(optional) The worker-pool sizing the dispatcher applies to this stage: the cap on concurrent worker processes (
PARALLELISM, default 4, started on demand and reaped when idle), the number of messages a worker handles before it is recycled (MAX_MESSAGES, default 1000), and how many messages are pipelined to one worker at once (QUEUE_LIMIT, default 4, so the stage’s in-flight capacity isQUEUE_LIMIT × PARALLELISMwhile the process and connection count stay set byPARALLELISM). See pepsi-dispatch.
New messages always enter at [stage-init], which must exist. Each
program reads its own options from its section; those are documented per program
under Programs.
6.4.1. Ordering constraints¶
The pipeline graph is yours to draw, but five orderings are not free choices.
pepsi-setup warns about each, and each produces mail that looks right.
Encryption comes before DKIM signing. The recommended outbound chain is:
submission → … → encrypt → srs → dkim-sign → relay
pepsi-stage-encrypt rewrites the body, and DKIM must sign the bytes that are actually transmitted. Signing first yields a message whose DKIM signature does not verify at the recipient — which is worse than no signature, because a broken signature is a stronger negative signal to a receiving MTA than an absent one.
Anything that reads the body comes before encryption. The pay-to-send gate (pepsi-stage-anti-spam) and language detection (pepsi-stage-detect-language) both want cleartext, and both get it, because encryption is last on the outbound path. That is not a coincidence to preserve by luck; it is the reason for the ordering above.
SRS runs on the inbound path only. Rewriting the envelope sender of our own users’ mail would break DMARC alignment — the address is already in a domain we control, so SPF passes without help.
ARC comes before decryption. The recommended inbound chain is:
init = arc → decrypt → aliases → (anti-spam / language / …) → local delivery
An ARC set seals the message as it arrived.
pepsi-stage-decrypt rewrites the body, so decrypting first would
make the sealed AMS describe bytes nobody else ever saw — a claim about a
message that never existed.
Decryption needs local delivery downstream. Decrypting invalidates the
sender’s DKIM signature, which is harmless for a message about to be filed in a
mailbox on this host and is not for one being relayed onward. The stage
therefore runs only for recipients this host serves and splits a message with
both kinds; pepsi-setup warns when no
pepsi-stage-relay-to-maildir or
pepsi-stage-relay-to-lmtp is reachable from a decrypt stage,
because such a stage is either pointless or harmful.
The mirror image of the outbound rule applies too: anything that reads the
body comes after decryption, which is why the content stages sit where they
do. Note that the decrypt stage loads the whole message body for every message
that passes through it (its Load is fixed per stage, and whether a message
is protected cannot be told from the envelope columns), so placement is a
performance matter as well as a correctness one.
6.5. Worked examples¶
Each example below is a complete, minimal pipeline for one job, with no language classification, ARC/DKIM signing or spam filtering — just the shared sections, one listener, and the stages that do the work. They all share the same shell:
[pepsi](onlyKEY_DIRhere — DKIM/ARC are not used in these examples),[pepsi-postgres]for the database, and[pepsi-ingress]plus one[pepsi-ingress-listener-*]socket.
The listeners set MODE = starttls/tls but no TLS_CERT/TLS_KEY:
pepsi-setup auto-fills the certbot paths (see Installation). Set them
explicitly, or pass --no-certbot, to manage the certificate yourself.
Each is a real, self-contained configuration — to layer ARC, SRS, DKIM signing
or spam filtering on top, insert the corresponding stages between init and
the delivery stage as the sample pepsi.conf shows.
6.5.1. Minimal inbound (relay to a smarthost)¶
An edge MX that accepts mail for our domains and hands every message to one
authenticated upstream smarthost. init is the relay stage; the smarthost
authenticates us, so no SRS rewrite is needed here:
[pepsi]
KEY_DIR = /var/pepsi/keys
[pepsi-postgres]
CONFIG = postgres:///pepsi
[pepsi-ingress]
HOSTNAME = mail.example.org
ACCEPTED_DOMAINS = example.org
[pepsi-ingress-listener-mx]
SERVE = tcp
BIND_TO = 0.0.0.0
PORT = 25
MODE = starttls
# init: relay every accepted message to the upstream smarthost. A successful
# delivery is terminal (no NEXT_STAGE).
[stage-init]
PROGRAM = pepsi-stage-relay-to-smarthost
SERVER_NAME = mail.example.org
# The upstream smarthost (one [...-mta-*] section per route; CATCH_ALL takes
# everything not matched by a DOMAINS list).
[pepsi-stage-relay-to-smarthost-mta-upstream]
HOST = smtp.relay.example.net
PORT = 587
MODE = starttls
AUTH = plain
USERNAME = relayuser
PASSWORD = changeme
CATCH_ALL = yes
6.5.2. Minimal outbound (relay direct to the internet)¶
A submission/relayhost that accepts our own users’ mail and delivers it directly
to each recipient’s MX. The listener trusts a local network (MYNETWORKS) so
those submissions are marked locally-originated and allowed to relay to any
domain; outbound mail keeps its own envelope sender, so there is no SRS stage:
[pepsi]
KEY_DIR = /var/pepsi/keys
[pepsi-postgres]
CONFIG = postgres:///pepsi
[pepsi-ingress]
HOSTNAME = mail.example.org
ACCEPTED_DOMAINS = example.org
[pepsi-ingress-listener-submission]
SERVE = tcp
BIND_TO = 0.0.0.0
PORT = 587
MODE = starttls
# Trust submissions from this network without authentication (use SASL_TYPE /
# TLS_AUTH_CLIENT instead for roaming users).
MYNETWORKS = 10.0.0.0/8
# init: deliver directly to each recipient's mail exchanger. Terminal.
[stage-init]
PROGRAM = pepsi-stage-relay-to-internet
SERVER_NAME = mail.example.org
# Read by pepsi-setup to build the SPF record listing our sending hosts. (This
# section's name is shared with the smarthost stage even when, as here, no
# smarthost is configured.)
[pepsi-stage-relay-to-smarthost]
PUBLIC_IP = 203.0.113.7 2001:db8::25
6.5.3. Minimal local delivery (relay to Maildir)¶
An MX that delivers mail for our domain into local users’ Maildir/new/. Every
recipient is in ACCEPTED_DOMAINS (so ingress accepts it) and resolves to a
local account, hence no NEXT_STAGE is needed — add one (a smarthost or bounce
stage) if a recipient in our domain may have no local mailbox:
[pepsi]
KEY_DIR = /var/pepsi/keys
[pepsi-postgres]
CONFIG = postgres:///pepsi
[pepsi-ingress]
HOSTNAME = mail.example.org
ACCEPTED_DOMAINS = example.org
[pepsi-ingress-listener-mx]
SERVE = tcp
BIND_TO = 0.0.0.0
PORT = 25
MODE = starttls
# init: write each local recipient's copy into their Maildir via the setuid
# helper. Terminal for local recipients.
[stage-init]
PROGRAM = pepsi-stage-relay-to-maildir
SERVER_NAME = mail.example.org
A staging deployment that never touches the network swaps the delivery stage for
pepsi-stage-discard (see pepsi-stage-discard).
6.6. Configuration in the database¶
The file described above is the base layer. On top of it, Pepsi reads a set of
administrator-managed overrides from the pepsi.config_override table, so a
running deployment can be reconfigured without editing files and without a
restart.
Nothing about this is on by default. With no rows in that table a deployment behaves exactly as its configuration file says — there is no implicit configuration hiding in the database, and deleting every override returns the system to the file’s behaviour.
6.6.1. The scope chain¶
Five layers, lowest precedence first:
Layer |
Written by |
Applies to |
|---|---|---|
the configuration file |
the operator, with a text editor |
everything |
|
|
everything |
|
|
messages whose relevant address is at that domain |
|
|
messages whose relevant address is exactly that one |
|
the account owner, by e-mail |
messages to/from that one address |
Higher layers override lower ones option by option, never section by section. An option that no layer mentions keeps its file value, so an override is always a targeted amendment rather than a replacement of a whole section.
The “relevant address” is the same one the per-address settings layer already
uses: the envelope sender for a locally-originated message, otherwise each
envelope recipient. When one message’s recipients resolve to different
configurations for the stage about to run, the message is split into one row per
distinct configuration, exactly as it already is for pepsi.settings.
6.6.2. Why pepsi.settings is a separate table¶
It would be tidier to keep both in one table, and it would be wrong. The two have different write authorities:
pepsi.settingsis written by account owners, from their own mailbox, through pepsi-stage-edit-settings, restricted to the stage sections the operator listed inEDITABLE_STAGES;config_overridedefines the pipeline and is writable only through thepepsi-configdatabase role.
Merging them would put user-writable rows in the same table as the definition of
the mail server, where one bug in a namespace check stops being an override leak
and becomes “a user reconfigured the MTA”. The separation is a security
boundary, not duplication. PostgreSQL enforces it: pepsi-setup grants
INSERT/UPDATE/DELETE on config_override to the pepsi-config
role alone, revokes them from every service account, and verifies both against
the live database after each install. A stage worker parses hostile mail for a
living; it may read the configuration and may not change it.
6.6.3. What stays in the configuration file¶
Some sections are never read from the database, whatever rows exist. An override naming one of them is ignored at run time and refused at write time:
[pepsi],[pepsi-postgres],[PATHS]Needed to find and open the database in the first place, and — for
[pepsi]— the home of the administrator-only cryptographic policy.[pepsi-httpd],[pepsi-httpd-listener-*],[pepsi-httpd-cert-*]The server that serves the configuration interface must always be able to start, whatever the database says.
[pepsi-ingress-listener-*]Listening sockets are bound at start-up, often under socket activation and before privileges are dropped.
[pepsi-secure-link]Its
PEPPERis the server-side secret that makes a stolen database useless (see The secure-link fallback portal). A database able to replace it could quietly arrange for the next message stored to be one the attacker can open — the same class of thing as lowering the crypto policy, and it gets the same answer.
The line is “needed before a database connection exists, or a security boundary”. Listeners are on the second side of it deliberately: if the database could move a listening socket or change its TLS material, a database compromise would become a compromise of the mail server’s own ports.
Important
Adding a submission port is a text-editor-and-restart operation. So is
changing a listener’s TLS certificate, the database connection, or the
[pepsi] crypto policy. No administrative interface can change these, and
none should imply that it can.
Two further options are file-only for a plainer reason — they are read to open
the database itself, before an overlay can exist: [pepsi-ingress]
DB_POOL_SIZE and [pepsi-dispatch] DB_POOL_SIZE.
6.6.4. Hot reload, and what needs a restart¶
Every write to the table fires a config_changed notification. What happens
next depends on the section:
[stage-*]— applied without a restart.pepsi-dispatchretires its stage workers when the notification arrives (no message is interrupted: a worker is given no further work and exits once its current messages are done), and their replacements read the new values. The next message is processed with the new configuration.- Everything else — needs a restart of the component that reads it.
[pepsi-ingress],[pepsi-srs],[pepsi-tlsrpt]and the rest are read once at start-up. The value is stored, and takes effect when that program is next started.pepsi-configsays which applies after every write.
Two things about the pipeline itself are read once by the dispatcher and are
therefore in the restart category even though they live in [stage-*]
sections: which stages exist and each one’s PROGRAM and PARALLELISM.
Changing a stage’s options is live; adding a stage to the pipeline is not.
A component that misses a notification — because its listener was disconnected — re-reads the overlay when the listener reconnects, so a lost notification costs latency and not correctness.
6.6.5. Secrets stay in files¶
The database stores only the @inline-secret@ reference, never a secret
value. This preserves the property the current design rests on: each fragment
under secrets.d is owned by the single reader that needs it, so an
unprivileged stage can read its own secret and nothing else. Encrypted values in
the database would hand every reader one key that opens everything.
6.6.6. Editing the overlay¶
pepsi-config set stage-relay MAX_LIFETIME '48 h'
pepsi-config set --scope domain:example.org stage-relay DELAY_DSN_AFTER '4 h'
pepsi-config unset stage-relay MAX_LIFETIME
pepsi-config list
Every write is validated first, by building the configuration the change would produce and running the owning stage’s real parser over it — the same check pepsi-stage-edit-settings applies to an e-mailed override. A value that would stop a program from starting is refused with that program’s own error message, and nothing is stored.
Should a bad value reach the table anyway, no program refuses to start because of it: an unreadable or unusable overlay is logged loudly and the configuration file is used unchanged.
6.6.7. Where a value came from¶
pepsi-config dump --origin
pepsi-config dump --origin --scope address:user@example.org
annotates every effective value with the layer that set it (file, or the
database scope), and each section with whether a change to it is applied live or
needs a restart. This is what makes a layered configuration debuggable rather
than mysterious.
6.6.8. Backing it up¶
The two halves are backed up by different tools, on purpose:
what is in the database — the overrides — is covered by
pg_dumpalong with the rest of the schema;what cannot be, because it must exist before a database connection does — the configuration file and the
secrets.dfragments — is covered bypepsi-config export, which writes a single password-encrypted archive.
The archive records each file’s mode, owner and group by name, and
pepsi-config import restores them; it refuses rather than guesses when a
named account does not exist on the target machine. That is not a nicety: an
archive that lost ownership would either break every unprivileged reader or
silently make a secret world-readable.
6.7. Validating¶
Always validate a configuration before relying on it:
pepsi-setup -c /etc/pepsi/pepsi.conf check # against live DNS
pepsi-config -c /etc/pepsi/pepsi.conf dump # effective values
pepsi-setup run refuses to change anything if the configuration is invalid:
it checks that [stage-init] exists, that every NEXT_STAGE/BOUNCE_STAGE
resolves, that each stage’s PROGRAM configuration parses, and that domains and
PUBLIC_IP values are well-formed.
It validates the database overlay too: a stored override must name a real scope, must not name a file-only section (which would be silently ignored), must name a stage section that exists, and the configuration it produces must still satisfy every affected stage’s own parser.