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:
parent
082d4c39eb
commit
d766d61e7f
12 changed files with 1029 additions and 98 deletions
113
integrations/postfix/README.md
Normal file
113
integrations/postfix/README.md
Normal 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
122
integrations/postfix/install.sh
Executable 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue