6. Configuration¶
All Pepsi components read one INI-style configuration file (the format is shared with the GNU Taler tools). 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, 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). The code default remainsAuthEnvelopedData, so deleting the file restores authenticated encryption immediately — which is 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.FUSION(optional) Whether a predecessor may run this stage in its own worker process instead of handing the row back to the dispatcher. Defaults to
YESwhenPROGRAMis one of the fast body-free stages (pepsi-stage-+if,discard,auto-whitelist,block-language,check-whitelist,srs,listoredit-settings) andNOotherwise;[pepsi] ALLOW_FUSION = NOdisables fusion globally.
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,
and getting any of them wrong produces mail that looks right. pepsi-setup
warns about three of them: a DKIM-signing stage that leads to an encrypt stage,
a decrypt stage that leads to an ARC stage, and a decrypt stage with no local
delivery downstream.
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 — which is the reason for the ordering above.
SRS is for forwarded mail, and harmless on the outbound relay tail. It
rewrites a sender in somebody else’s domain — mail this host forwards — so that
SPF passes at the next hop. The null sender and a sender already in the SRS
domain are left alone, so with SRS_DOMAIN set to the primary domain (the
wizard’s default) our own users’ mail passes through unchanged, and a relay tail
shared by forwarded and submitted mail can run it before signing, in the order
pepsi-setup checks for: encrypt → srs → dkim-sign → relay. A user of a
further served domain is rewritten too; that costs SPF alignment for their
domain, but DMARC still passes on the aligned DKIM signature dkim-sign adds
afterwards.
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, and
# to check each address's PTR against SERVER_NAME above. PUBLIC_IP belongs in
# the relay *stage's* section: pepsi-setup finds it by walking [stage-*] and
# keeping the sections whose PROGRAM is a relay, never by the program's own
# section name.
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
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 for pepsi.settings.
The domain: and address: layers hold [stage-*] sections only: a
stage consults them for the section it is running, per message. Every other
section is read once per process and sees the file plus the global layer, so
pepsi-config refuses to store a scoped override of one and pepsi-setup
run reports any such row as an error.
6.6.2. Why pepsi.settings is a separate table¶
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”. PostgreSQL enforces the separation:
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.[pepsi-admin]Decides who may administer the server and how the administrative surface reaches the database; a database opinion about it would be a way to grant yourself the console.
[pepsi-crypto],[pepsi-srs],[pepsi-origin]Each holds a server-side key that exists so that the database alone is not enough: the key-encryption keys (and which of them wraps new private keys), the SRS key that authorises relaying to any address, and the proof-of-origin key a returning bounce or payment demand is checked against. A database able to replace one could arrange for the next wrapped key to be one an attacker can open, or for a forged bounce to be relayed or paid. The rest of each section rides along.
[pepsi-wizard]Here for a third reason: it is not configuration at all, but the setup wizard’s record of the answers it was given — which the browser interview stages as draft rows in this very table. Drafts are invisible to every reader, but a hand-written non-draft row would otherwise land in the effective configuration of every process. Nothing reads the section at run time, so the safe answer is that the database never supplies it either.
The line is “needed before a database connection exists, a security boundary, or the home of a server-side secret”. 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.
Credentials are never stored in the database, whatever section they are in. An
option whose name marks it as one — containing PASS, SECRET, TOKEN,
CREDENTIAL, CLIENT_ID or PEPPER, the rule that masks a value
wherever a configuration is shown — is refused by pepsi-config set and by the
administrative API, and ignored if a row for one exists anyway. The rule covers
the file a credential is read from and the endpoint it is sent to as well
(TOKEN_FILE, TOKEN_ENDPOINT), since a database able to change those can
redirect the credential. Put them in the file or in a secrets.d fragment.
Likewise a domain: or address: override is accepted for a [stage-*]
section only (see above: nothing else reads those layers), and refused for any
other section rather than stored where nothing will read it.
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-dispatchdoes two things when the notification arrives: it rebuilds its own view of the pipeline from the overlay — which stages exist, and each one’sPROGRAM,PARALLELISM,MAX_MESSAGESandQUEUE_LIMIT— and it retires its stage workers (no message is interrupted: a worker is given no further work and exits once its current messages are done), so their replacements read the new values. The next message is routed and run with the new configuration, which is what makes adding, editing and removing a stage all live. The one hard edge: if the reloaded graph does not parse, the dispatcher shuts down and exitsEX_CONFIGrather than route by a pipeline its workers no longer share. The shipped unit restarts it (Restart=always, backing off to once a minute), so it resumes on its own once the configuration is fixed. An overlay that cannot be read (a database error) is different: the dispatcher keeps its current stage graph and retries the reload, rather than dropping every stage defined only in the database.- 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.
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, so each fragment under secrets.d stays owned by the single reader that
needs it: 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 row reach the table anyway, what happens depends on how bad it is.
A row that cannot be represented in the configuration (an unknown scope, a
file-only section, a malformed name) is logged loudly and skipped; an overlay
that cannot be read at all, or whose merged text does not parse, is logged and
the program runs on the configuration file alone. A value that is well-formed but that the program itself rejects
— an unparseable duration, a NEXT_STAGE naming no stage — is not skipped:
it is part of the effective configuration, and the program fails on it exactly
as it would on the same value in the file. pepsi-setup run checks for both
(see Validating).
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.
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. 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 not put
anything but a [stage-*] section in a domain: or address: scope, and
must name a stage section that exists — in the file or defined by the overlay
itself, since the dispatcher loads a stage added with pepsi-config set
without a restart. The configuration it produces must then still satisfy every
affected stage’s own parser: the global layer over the file, and each
domain:/address: scope’s chain over that, each running the parsers of
the stages it touches.