MAS-WP-0004: Postfix integration profile, corpus probe in pytest, Exim notes

Profile in integrations/postfix (install.sh + README), now used by the
live deployment. Probe runs every conformance-corpus local-part and the
relay negatives against it; tests/test_postfix_profile.py runs it where
Docker is available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
This commit is contained in:
tegwick 2026-10-19 09:25:03 +02:00
parent 082d4c39eb
commit d766d61e7f
12 changed files with 1029 additions and 98 deletions

View file

@ -0,0 +1,51 @@
# Exim notes (MAS-WP-0004 T03)
No Exim profile is shipped, because there is no demand yet. These notes say
what one would need. The ACL finding was checked against the stock config
of Debian bookworm `exim4-daemon-light` (Exim 4.96, 2026-10-19). The router
and transport points come from the Exim documentation and are untested.
## Pitfalls
- **Stock ACL rejects every P2 address.** `acl_check_rcpt` denies local
domains' local-parts that match `CHECK_RCPT_LOCAL_LOCALPARTS`, which
defaults to `^[.] : ^.*[@%!/|`#&?]`. So `ticket!callback@D` and
`ticket=PR%2B42@D` get `550 restricted characters in address`. This is the
same failure class as Postfix's `reject_unauth_destination`. Upstream
Exim's example `configure` has the same rule (`^[.] : ^.*[@%!/|]`).
- **Percent hack** is off by default (`percent_hack_domains` unset), so `%`
is not rewritten once the ACL lets it through. Exim has no bang-path
rewriting.
- **Case.** Routers lower-case `$local_part` unless `caseful_local_part =
true`, which would hide `Ticket=PR42` (invalid in MAS) as `ticket=PR42`.
- **Suffixes.** A router with `local_part_suffix = +*` strips facets before
the transport sees them. Do not set it on the MAS router.
## Settings a profile would need
```
# main section: drop % and ! from the local-domain restriction
CHECK_RCPT_LOCAL_LOCALPARTS = ^[.] : ^.*[@/|`#&?]
# router, first for the MAS domain
mas:
driver = accept
domains = D
caseful_local_part = true
transport = mas_pipe
# transport: one recipient per call, envelope recipient as received
mas_pipe:
driver = pipe
batch_max = 1
user = mas
command = /usr/local/bin/mas-gateway --statute <statute> \
--trusted-authserv-id D pipe --app-url <url> \
--recipient ${quote:$local_part}@$domain
```
Keep `CHECK_RCPT_REMOTE_LOCALPARTS` as it is: relaying stays closed, and only
D is a local domain. Sender authentication would use Exim's built-in
`dkim`/`spf`/`dmarc` ACL conditions with `add_header =
Authentication-Results: D; ...`. Incoming A-R headers for D must be removed
first (`headers_remove`).

View file

@ -0,0 +1,113 @@
# MAS Postfix integration profile
Configures an installed Postfix (tested: 3.7, Debian bookworm) to receive
mail for one MAS domain and hand every recipient, unchanged, to
`mas-gateway`. This is the setup that runs live on mas.coulomb.social
(`deploy/railiance/entrypoint.sh`). It is tested by
`experiments/postfix-probe` and `tests/test_postfix_profile.py`.
```sh
integrations/postfix/install.sh --domain great-service.example \
--statute /srv/statute/.well-known/mas \
--app-url http://127.0.0.1:8024/mas-events --auth
opendkim -f -x /etc/opendkim.conf & # only with --auth
opendmarc -f -c /etc/opendmarc.conf & # only with --auth
postfix start-fg
```
The script needs a `mas` system user and `mas-gateway` on the path (or
`--gateway CMD`). It writes `/etc/postfix/mas_rcpt.regexp` and, with
`--auth`, also `/etc/postfix/ar_strip.regexp`, `/etc/opendkim.conf` and
`/etc/opendmarc.conf`.
## Why the stock configuration fails
A default Postfix silently loses every P2 address (`ticket!callback@D`)
(`docs/experiment-review.md`, *Email infrastructure*):
- `reject_unauth_destination` treats a local-part that contains `!` or `%`
as sender-specified routing and refuses it, even for a local domain.
- When relaying is allowed, `swap_bangpath=yes` rewrites `a!b@D` to `b@a`
and `allow_percent_hack=yes` rewrites `a%b@D` to `a@b`. The mail then
goes somewhere else, or bounces.
- An access-map `OK` for such an address is ignored unless
`allow_untrusted_routing=yes`.
## main.cf
| Setting | Why |
|---|---|
| `mydestination = D`, `relay_domains =` | D is the only domain received; nothing is relayed. |
| `mynetworks = 127.0.0.0/8` | Only loopback (your app's replies) may send out. |
| `smtpd_relay_restrictions = permit_mynetworks, check_recipient_access regexp:/etc/postfix/mas_rcpt.regexp, reject` | Replaces `reject_unauth_destination`. The final `reject` refuses everything else, and smtpd will not start without a reject-class entry. |
| `allow_untrusted_routing = yes` | Without it, the regexp result is ignored for `!`/`%` local-parts. It is safe only because `relay_domains` is empty and nothing rewrites such addresses. |
| `swap_bangpath = no`, `allow_percent_hack = no` | Never rewrite MAS syntax. |
| `local_transport = mas`, `local_recipient_maps =` | Every recipient of D goes to the MAS pipe, with no local-user lookup. The gateway and statute decide. |
| `mas_destination_recipient_limit = 1` | One recipient per gateway call, so `${recipient}` is that recipient. |
| `recipient_delimiter = +` | Postfix knows that facets exist. `${recipient}` is unaffected. |
| `disable_vrfy_command`, `smtpd_helo_required`, `smtpd_recipient_limit = 20` | Hygiene for a receive-only endpoint. |
`mas_rcpt.regexp` (dots in D escaped):
```
/@great-service\.example$/ permit
```
The match runs the `permit` restriction rather than returning `OK`.
## master.cf
```
mas unix - n n - - pipe
flags=Rq user=mas argv=/usr/local/bin/mas-gateway
--statute <statute> --trusted-authserv-id D
pipe --app-url <url> --recipient ${recipient}
```
`flags=q` quotes the recipient for the shell-free argv. Postfix passes
`${recipient}` in RFC 5321 canonical form, which can differ from the bytes
sent: needless quotes are dropped (`"ticket=PR42"` becomes `ticket=PR42`) and
quotes are added around non-dot-atoms (`.bernd` becomes `".bernd"`). Spec §3
treats both forms as equivalent, and a re-quoted form is never a valid MAS
address.
## Outbound (`--relay HOST:PORT --relay-credentials DIR`)
Only loopback may send out (`mynetworks`), so outbound mail is your app's
replies. With `--relay`, they go through an authenticated submission relay
(`relayhost`, `smtp_sasl_*`, `smtp_tls_security_level = secure`); DIR holds
the files `username` and `password`. Postfix's own bounces (null sender)
are discarded rather than relayed
(`sender_dependent_default_transport_maps = inline:{<>=discard:}`). Then
the app's own send cap bounds everything sent through the relay. The
reference ticket service has `--max-sent`. Without `--relay`,
`default_transport = error:` makes outbound mail fail at once instead of
queueing.
## Sender authentication (`--auth`)
The gateway trusts `Authentication-Results` only for its own authserv-id
(spec §14 item 10). The MTA does SPF/DKIM/DMARC, because these need DNS:
- OpenDKIM in verify mode (`Mode v`) and OpenDMARC with
`SPFSelfValidate true` both write A-R headers with authserv-id D.
`RejectFailures false` and `milter_default_action = accept` mean they
annotate only: they never reject, and mail is accepted if a milter is
down. Loopback is in OpenDKIM's `PeerList`, so your own replies are
skipped.
- `header_checks = regexp:/etc/postfix/ar_strip.regexp` drops every
incoming `Authentication-Results: D ...` header. Header checks do not see
headers that milters add, so only the milters' own results survive, and a
sender cannot forge a pass. OpenDKIM's `RemoveARFrom` did not remove
them in testing.
- Run both milters in the foreground (`-f`). Daemonized, OpenDKIM died on
its first connection in a container.
## Not covered here
- The send cap itself is the application's job (ticket service
`--max-sent`). The application must never reply to a null sender
(spec §14).
- TLS certificates, banner, size limits and chroot: deployment specifics,
see `deploy/railiance/entrypoint.sh`.
- Exim: see `integrations/exim/NOTES.md`.

122
integrations/postfix/install.sh Executable file
View file

@ -0,0 +1,122 @@
#!/bin/sh
# MAS Postfix integration profile (MAS-WP-0004). Configures an installed
# Postfix to receive mail for one MAS domain and hand every recipient to
# mas-gateway. See README.md for why each setting is needed.
#
# install.sh --domain D --statute FILE --app-url URL [--gateway CMD] [--auth]
# [--relay HOST:PORT --relay-credentials DIR]
#
# --relay sends outbound mail (the app's replies) through an authenticated
# submission relay; DIR holds the files username and password. Without it
# all outbound mail fails fast.
# --auth adds sender authentication (OpenDKIM verify + OpenDMARC milters,
# annotate-only) and strips forged Authentication-Results for D. The caller
# starts the milters (opendkim -f -x /etc/opendkim.conf, opendmarc -f -c
# /etc/opendmarc.conf) and Postfix.
set -e
GATEWAY=/usr/local/bin/mas-gateway
AUTH=no
while [ $# -gt 0 ]; do
case "$1" in
--domain) DOMAIN=$2; shift 2 ;;
--statute) STATUTE=$2; shift 2 ;;
--app-url) APP_URL=$2; shift 2 ;;
--gateway) GATEWAY=$2; shift 2 ;;
--auth) AUTH=yes; shift ;;
--relay) RELAY=$2; shift 2 ;;
--relay-credentials) RELAY_CREDS=$2; shift 2 ;;
*) echo "install.sh: unknown argument $1" >&2; exit 64 ;;
esac
done
: "${DOMAIN:?--domain is required}" "${STATUTE:?--statute is required}" "${APP_URL:?--app-url is required}"
DOMAIN_RE=$(printf '%s' "$DOMAIN" | sed 's/\./\\./g')
# 1. Relay control. Stock reject_unauth_destination refuses every local-part
# containing '!' or '%' (sender-specified routing), so it cannot be used.
# An explicit regexp permits only @DOMAIN; everything else hits the final
# 'reject' (smtpd will not start without a reject-class entry).
# allow_untrusted_routing=yes is needed, otherwise Postfix ignores the
# regexp result for '!'/'%' local-parts. It is safe here because
# relay_domains is empty and nothing rewrites such addresses (2.).
printf '/@%s$/ permit\n' "$DOMAIN_RE" > /etc/postfix/mas_rcpt.regexp
postconf -e \
"myhostname=$DOMAIN" "mydomain=$DOMAIN" "mydestination=$DOMAIN" "relay_domains=" \
"mynetworks=127.0.0.0/8" \
"smtpd_relay_restrictions=permit_mynetworks,check_recipient_access regexp:/etc/postfix/mas_rcpt.regexp,reject" \
"allow_untrusted_routing=yes"
# 2. Never rewrite MAS syntax: no bang-path swap, no percent hack.
postconf -e "swap_bangpath=no" "allow_percent_hack=no"
# 3. Every recipient of DOMAIN goes to the mas pipe, one recipient per call,
# as the envelope recipient Postfix received. recipient_delimiter=+ does
# not change ${recipient}; it only lets Postfix know facets exist.
postconf -e "local_transport=mas" "local_recipient_maps=" \
"mas_destination_recipient_limit=1" "recipient_delimiter=+"
if ! grep -q '^mas[[:space:]]' /etc/postfix/master.cf; then
cat >> /etc/postfix/master.cf <<CF
mas unix - n n - - pipe
flags=Rq user=mas argv=$GATEWAY
--statute $STATUTE --trusted-authserv-id $DOMAIN
pipe --app-url $APP_URL --recipient \${recipient}
CF
fi
# 4. Hygiene for a mail endpoint that only receives.
postconf -e "disable_vrfy_command=yes" "smtpd_helo_required=yes" "smtpd_recipient_limit=20"
# 5. Outbound. Only loopback may send (mynetworks), so this is the app's
# replies. Postfix's own bounces (null sender) are discarded rather than
# relayed, so the app's send cap bounds everything sent through the relay.
if [ -n "$RELAY" ]; then
: "${RELAY_CREDS:?--relay needs --relay-credentials}"
printf '%s %s:%s\n' "$RELAY" "$(cat "$RELAY_CREDS/username")" \
"$(cat "$RELAY_CREDS/password")" > /etc/postfix/sasl_passwd
chmod 600 /etc/postfix/sasl_passwd && postmap /etc/postfix/sasl_passwd
postconf -e "relayhost=$RELAY" "smtp_sasl_auth_enable=yes" \
"smtp_sasl_password_maps=hash:/etc/postfix/sasl_passwd" \
"smtp_sasl_security_options=noanonymous" "smtp_tls_security_level=secure" \
"smtp_tls_CApath=/etc/ssl/certs" \
"sender_dependent_default_transport_maps=inline:{<>=discard:}" \
"empty_address_default_transport_maps_lookup_key=<>"
else
postconf -e "default_transport=error:5.7.1 outbound mail disabled"
fi
[ "$AUTH" = yes ] || exit 0
# 6. Sender authentication (spec 14 item 10): annotate only, never reject,
# accept if a milter is down. Our own replies (loopback) are skipped.
# header_checks drop incoming A-R headers claiming DOMAIN; they do not
# apply to milter-added headers, so only the milters' results survive.
# (OpenDKIM's RemoveARFrom did not remove them in testing.)
mkdir -p /run/opendkim /run/opendmarc
chown opendkim:opendkim /run/opendkim && chown opendmarc:opendmarc /run/opendmarc
printf '127.0.0.1\n' > /etc/opendkim-peers
cat > /etc/opendkim.conf <<CONF
Mode v
Socket inet:8891@127.0.0.1
AuthservID $DOMAIN
PeerList /etc/opendkim-peers
UserID opendkim
PidFile /run/opendkim/opendkim.pid
Syslog no
CONF
cat > /etc/opendmarc.conf <<CONF
Socket inet:8893@127.0.0.1
AuthservID $DOMAIN
TrustedAuthservIDs $DOMAIN
SPFSelfValidate true
SPFIgnoreResults true
RejectFailures false
PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat
UserID opendmarc
PidFile /run/opendmarc/opendmarc.pid
Syslog false
CONF
printf '/^Authentication-Results:[[:space:]]*%s([[:space:];]|$)/ IGNORE\n' "$DOMAIN_RE" \
> /etc/postfix/ar_strip.regexp
postconf -e "header_checks=regexp:/etc/postfix/ar_strip.regexp" \
"smtpd_milters=inet:127.0.0.1:8891,inet:127.0.0.1:8893" "non_smtpd_milters=" \
"milter_default_action=accept" "milter_protocol=6"