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
|
|
@ -8,6 +8,7 @@ COPY mas_core /app/mas_core
|
|||
COPY mas_gateway /app/mas_gateway
|
||||
COPY examples /app/examples
|
||||
COPY experiments/postfix-probe/capture.py /app/capture.py
|
||||
COPY integrations /app/integrations
|
||||
COPY deploy/railiance/entrypoint.sh /entrypoint.sh
|
||||
ENV PYTHONPATH=/app
|
||||
EXPOSE 25 8080
|
||||
|
|
|
|||
|
|
@ -15,28 +15,22 @@ exec env PYTHONPATH=/app python3 -m mas_gateway.main "\$@"
|
|||
SH
|
||||
chmod +x /usr/local/bin/mas-gateway
|
||||
|
||||
# Not an open relay: no relay_domains, mynetworks is loopback only, so the
|
||||
# only accepted recipients are @$DOMAIN, all delivered to the mas pipe.
|
||||
# Stock reject_unauth_destination refuses any local-part containing '!' or '%'
|
||||
# (treated as sender-specified routing), which kills every P2 address, and an
|
||||
# access-map result is ignored for such routed addresses unless
|
||||
# allow_untrusted_routing=yes (safe here: relay_domains is empty). So relay control is an
|
||||
# explicit regexp whose match runs the `permit` restriction (not OK) for
|
||||
# anything ending in @$DOMAIN; everything else falls through to `reject`
|
||||
# (Postfix refuses to start smtpd without a reject-class entry in the list).
|
||||
# With swap_bangpath/percent_hack off and local_transport=mas, those
|
||||
# addresses can only reach the mas pipe.
|
||||
DOMAIN_RE=$(printf '%s' "$DOMAIN" | sed 's/\./\\./g')
|
||||
printf '/@%s$/ permit\n' "$DOMAIN_RE" > /etc/postfix/mas_rcpt.regexp
|
||||
# The MAS setup (relay control, no rewriting, mas pipe, outbound relay, sender
|
||||
# authentication) is the integration profile; see integrations/postfix/.
|
||||
# Port 25 out is blocked on railiance01, so replies go through an
|
||||
# authenticated submission relay, and only when its credentials are mounted.
|
||||
RELAY_ARGS=""
|
||||
if [ -n "$MAS_RELAY" ] && [ -s /etc/mas-relay/username ]; then
|
||||
RELAY_ARGS="--relay $MAS_RELAY --relay-credentials /etc/mas-relay"
|
||||
fi
|
||||
/app/integrations/postfix/install.sh --domain "$DOMAIN" \
|
||||
--statute "$STATUTE_DIR/.well-known/mas" --app-url http://127.0.0.1:8024/mas-events --auth \
|
||||
$RELAY_ARGS
|
||||
|
||||
# Deployment specifics.
|
||||
postconf -e \
|
||||
"maillog_file=/dev/stdout" "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" \
|
||||
"local_transport=mas" "local_recipient_maps=" "mas_destination_recipient_limit=1" \
|
||||
"recipient_delimiter=+" "swap_bangpath=no" "allow_percent_hack=no" \
|
||||
"allow_untrusted_routing=yes" \
|
||||
"disable_vrfy_command=yes" "smtpd_helo_required=yes" "smtpd_recipient_limit=20" \
|
||||
"message_size_limit=10240000" "smtpd_banner=\$myhostname ESMTP MAS live test" \
|
||||
"maillog_file=/dev/stdout" "message_size_limit=10240000" \
|
||||
"smtpd_banner=\$myhostname ESMTP MAS live test" \
|
||||
"smtpd_tls_security_level=may" \
|
||||
"smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem" \
|
||||
"smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key" \
|
||||
|
|
@ -46,74 +40,16 @@ postconf -e \
|
|||
# container already isolates Postfix, so run the daemons unchrooted.
|
||||
postconf -F '*/*/chroot = n'
|
||||
|
||||
# Outbound replies (reply test). Port 25 out is blocked on railiance01, so
|
||||
# replies go through an authenticated submission relay, and only when its
|
||||
# credentials are mounted. Only loopback (the ticket service) may relay.
|
||||
# Without a relay all non-local mail fails fast instead of queueing.
|
||||
# The ticket service sends replies only with a relay, capped at MAS_MAX_SENT.
|
||||
SMTP_ARG=""
|
||||
if [ -n "$MAS_RELAY" ] && [ -s /etc/mas-relay/username ]; then
|
||||
printf '%s %s:%s\n' "$MAS_RELAY" "$(cat /etc/mas-relay/username)" \
|
||||
"$(cat /etc/mas-relay/password)" > /etc/postfix/sasl_passwd
|
||||
chmod 600 /etc/postfix/sasl_passwd && postmap /etc/postfix/sasl_passwd
|
||||
postconf -e "relayhost=$MAS_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"
|
||||
# Postfix's own bounces (null sender) never use the relay, so the ticket
|
||||
# service cap below really bounds everything sent through TEM.
|
||||
postconf -e "sender_dependent_default_transport_maps=inline:{<>=discard:}" \
|
||||
"empty_address_default_transport_maps_lookup_key=<>"
|
||||
SMTP_ARG="--smtp 127.0.0.1:25 --max-sent ${MAS_MAX_SENT:-300} --require-dmarc-pass"
|
||||
else
|
||||
postconf -e "default_transport=error:5.7.1 outbound mail disabled"
|
||||
fi
|
||||
|
||||
# Sender authentication (spec 14 item 10). The MTA does SPF/DKIM/DMARC, since
|
||||
# they need DNS; the gateway only reads Authentication-Results carrying our
|
||||
# authserv-id. Postfix header_checks drop incoming A-R headers claiming that
|
||||
# id; header_checks do not see milter-added headers, so only the milters'
|
||||
# own results survive and a sender cannot forge a pass. (OpenDKIM's
|
||||
# RemoveARFrom did not remove them in testing.) Annotate only: never reject,
|
||||
# and accept mail if a milter is down. Loopback (the ticket service's
|
||||
# replies) is skipped. Both milters run in the foreground: daemonized,
|
||||
# OpenDKIM died on its first connection in this container.
|
||||
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
|
||||
# Milters in the foreground: daemonized, OpenDKIM died on its first
|
||||
# connection in this container.
|
||||
opendkim -f -x /etc/opendkim.conf &
|
||||
opendmarc -f -c /etc/opendmarc.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"
|
||||
|
||||
cat >> /etc/postfix/master.cf <<CF
|
||||
mas unix - n n - - pipe
|
||||
flags=Rq user=mas argv=/usr/local/bin/mas-gateway
|
||||
--statute $STATUTE_DIR/.well-known/mas --trusted-authserv-id $DOMAIN
|
||||
pipe --app-url http://127.0.0.1:8024/mas-events --recipient \${recipient}
|
||||
CF
|
||||
|
||||
mkdir -p /var/lib/mas && chown mas /var/lib/mas
|
||||
# Replies are only recorded in the outbox unless a relay is configured above.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Real Postfix -> pipe transport -> mas-gateway -> reference ticket service.
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
postfix python3 python3-jsonschema ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
postfix opendkim opendmarc publicsuffix python3 python3-jsonschema ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd -r -s /usr/sbin/nologin mas
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
EXPOSE 25 8024 8025
|
||||
|
|
|
|||
|
|
@ -7,14 +7,20 @@ probe can tell whether Postfix rewrote anything.
|
|||
|
||||
```bash
|
||||
docker build -t mas-postfix-probe experiments/postfix-probe
|
||||
python3 experiments/postfix-probe/probe.py --profile mas-profile --corpus # the shipped profile
|
||||
python3 experiments/postfix-probe/probe.py --profile defaults # relay domain
|
||||
python3 experiments/postfix-probe/probe.py --profile local-domain # domain in mydestination
|
||||
python3 experiments/postfix-probe/probe.py --profile local-domain-safe # + swap_bangpath/percent_hack off
|
||||
```
|
||||
|
||||
The other profiles are `defaults-local` and `mas-safe-local`. The repo is
|
||||
mounted read-only. In `results/` a `=` mark means the address arrived
|
||||
unchanged and `!` means it was rewritten. Findings are in
|
||||
`mas-profile` runs the shipped integration profile
|
||||
(`integrations/postfix/`) with `--corpus` (every conformance-corpus
|
||||
local-part), plus relay attempts that must be refused. `pytest` runs it as
|
||||
`tests/test_postfix_profile.py` when Docker is available. The other
|
||||
experiment profiles are `defaults-local` and `mas-safe-local`. The repo is
|
||||
mounted read-only. In the output, `=` means the address arrived unchanged,
|
||||
`~` means only its RFC 5321 quoting changed, `!` means it was rewritten, and
|
||||
`R` means a relay attempt was refused. Findings are in
|
||||
`docs/experiment-review.md` under *Email infrastructure*.
|
||||
|
||||
The Postfix side is the `master.cf` entry in `entrypoint.sh`:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,24 @@ exec env PYTHONPATH=/mas python3 -m mas_gateway.main "$@"
|
|||
SH
|
||||
chmod +x /usr/local/bin/mas-gateway
|
||||
|
||||
# mas-profile: the shipped integration profile (integrations/postfix), as
|
||||
# deployed live. Relaying is loopback-only, so the probe client is an ordinary
|
||||
# outside sender. The other profiles below are the MAS-WP-0002 experiments.
|
||||
if [ "$PROFILE" = "mas-profile" ]; then
|
||||
postconf -e "maillog_file=/dev/stdout" "compatibility_level=3.6"
|
||||
sh /mas/integrations/postfix/install.sh --domain great-service.example \
|
||||
--statute /mas/examples/statute/great-service.example/.well-known/mas \
|
||||
--app-url http://127.0.0.1:8024/mas-events --auth
|
||||
opendkim -f -x /etc/opendkim.conf &
|
||||
opendmarc -f -c /etc/opendmarc.conf &
|
||||
mkdir -p /var/lib/tickets && chown mas /var/lib/tickets
|
||||
su -s /bin/sh mas -c "PYTHONPATH=/mas python3 /mas/examples/ticket_service/ticket_service.py \
|
||||
--db /var/lib/tickets/t.sqlite3 --listen 0.0.0.0:8025" &
|
||||
python3 /mas/experiments/postfix-probe/capture.py http://127.0.0.1:8025/mas-events &
|
||||
echo "PROFILE=mas-profile"
|
||||
exec postfix start-fg
|
||||
fi
|
||||
|
||||
postconf -e "maillog_file=/dev/stdout" "myhostname=mx.probe.test" "mydestination=" \
|
||||
"relay_domains=great-service.example" "transport_maps=inline:{great-service.example=mas:}" \
|
||||
"mynetworks=0.0.0.0/0" "smtpd_relay_restrictions=permit_mynetworks,reject" \
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
python experiments/postfix-probe/probe.py --profile defaults
|
||||
python experiments/postfix-probe/probe.py --profile defaults-local
|
||||
python experiments/postfix-probe/probe.py --profile mas-safe-local
|
||||
python experiments/postfix-probe/probe.py --profile mas-profile --corpus
|
||||
|
||||
Starts the mas-postfix-probe container, sends one message per address via
|
||||
SMTP, then compares the envelope recipient Postfix handed to mas-gateway
|
||||
|
|
@ -38,9 +39,33 @@ CASES = [
|
|||
("quoted local-part", '"ticket=PR42"'),
|
||||
("unknown resource", "ticket=DOES-NOT-EXIST"),
|
||||
]
|
||||
# Relay attempts: the MAS profile must refuse all of them (not an open relay).
|
||||
RELAY_NEGATIVES = ["x@other.example", "x!y@other.example", "a@other.example@" + D,
|
||||
"a@" + D + "@other.example", "x%y@other.example"]
|
||||
|
||||
|
||||
def run(profile, port):
|
||||
|
||||
|
||||
def unquoted(addr):
|
||||
"""Strip RFC 5321 quoting. Postfix hands ${recipient} over in canonical
|
||||
form: needless quotes dropped, quotes added around non-dot-atoms."""
|
||||
local, _, dom = (addr or "").rpartition("@")
|
||||
if len(local) > 1 and local[0] == local[-1] == '"':
|
||||
local = local[1:-1]
|
||||
return f"{local}@{dom}"
|
||||
|
||||
|
||||
def corpus_cases():
|
||||
"""Every distinct local-part of the shared conformance corpus."""
|
||||
seen, out = set(), []
|
||||
for c in json.loads((ROOT / "mas_conformance/addresses.json").read_text()):
|
||||
if c["local_part"] not in seen:
|
||||
seen.add(c["local_part"])
|
||||
out.append((f"corpus {c['id']}", c["local_part"]))
|
||||
return out
|
||||
|
||||
|
||||
def run(profile, port, corpus=False):
|
||||
name = f"mas-probe-{profile}"
|
||||
subprocess.run(["docker", "rm", "-f", name], capture_output=True)
|
||||
subprocess.run(["docker", "run", "-d", "--name", name, "-e", f"PROFILE={profile}",
|
||||
|
|
@ -55,7 +80,7 @@ def run(profile, port):
|
|||
except OSError:
|
||||
time.sleep(0.5)
|
||||
results, tid = [], None
|
||||
for label, lp in CASES:
|
||||
for label, lp in CASES + (corpus_cases() if corpus else []):
|
||||
rcpt_lp = lp.replace("{ID}", tid or "MISSING")
|
||||
rcpt = f"{rcpt_lp}@{D}"
|
||||
msg = (f"From: probe@example.net\r\nTo: {rcpt}\r\nSubject: {label}\r\n"
|
||||
|
|
@ -65,7 +90,7 @@ def run(profile, port):
|
|||
with smtplib.SMTP("127.0.0.1", port, timeout=10) as s:
|
||||
refused = s.sendmail("probe@example.net", [rcpt], msg)
|
||||
smtp = "accepted" if not refused else f"refused {refused}"
|
||||
except smtplib.SMTPException as e:
|
||||
except (smtplib.SMTPException, UnicodeEncodeError) as e:
|
||||
smtp = f"rejected: {e}"
|
||||
got = wait_event(port, before) if smtp == "accepted" else None
|
||||
arrived = got["event"]["recipient"] if got else None
|
||||
|
|
@ -75,9 +100,19 @@ def run(profile, port):
|
|||
tid = app.get("ticket_id")
|
||||
results.append({"label": label, "sent": rcpt, "smtp": smtp, "arrived_as": arrived,
|
||||
"unchanged": arrived == rcpt,
|
||||
"equivalent": arrived is not None and unquoted(arrived) == unquoted(rcpt),
|
||||
"mas_valid": mas.get("valid"), "kind": mas.get("kind"),
|
||||
"app_status": got["app_status"] if got else None,
|
||||
"app": (app or {}).get("status") if isinstance(app, dict) else app})
|
||||
for rcpt in RELAY_NEGATIVES:
|
||||
try:
|
||||
with smtplib.SMTP("127.0.0.1", port, timeout=10) as s:
|
||||
refused = s.sendmail("probe@example.net", [rcpt], f"Subject: relay\r\n\r\nx\r\n")
|
||||
smtp = "accepted" if not refused else f"refused {refused}"
|
||||
except smtplib.SMTPException as e:
|
||||
smtp = f"rejected: {e}"
|
||||
results.append({"label": "relay attempt", "sent": rcpt, "smtp": smtp, "arrived_as": None,
|
||||
"unchanged": False, "relay_refused": smtp != "accepted"})
|
||||
logs = subprocess.run(["docker", "logs", name], capture_output=True, text=True)
|
||||
return results, logs.stdout + logs.stderr
|
||||
finally:
|
||||
|
|
@ -101,16 +136,18 @@ def wait_event(port, before, timeout=15):
|
|||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--profile", choices=["defaults", "defaults-local", "mas-safe-local", "local-domain", "local-domain-safe"], default="defaults")
|
||||
ap.add_argument("--profile", choices=["mas-profile", "defaults", "defaults-local", "mas-safe-local",
|
||||
"local-domain", "local-domain-safe"], default="mas-profile")
|
||||
ap.add_argument("--port", type=int, default=2525)
|
||||
ap.add_argument("--corpus", action="store_true", help="also send every conformance-corpus local-part")
|
||||
ap.add_argument("--json", metavar="FILE")
|
||||
ap.add_argument("--log", metavar="FILE")
|
||||
a = ap.parse_args()
|
||||
results, log = run(a.profile, a.port)
|
||||
results, log = run(a.profile, a.port, a.corpus)
|
||||
for r in results:
|
||||
mark = "=" if r["unchanged"] else "!"
|
||||
mark = ("R" if r["relay_refused"] else "X") if "relay_refused" in r else ("=" if r["unchanged"] else "~" if r["equivalent"] else "!")
|
||||
print(f"{mark} {r['label']:<22} {r['sent']:<52} -> {r['arrived_as'] or r['smtp']}"
|
||||
f" [{r['kind']}/{r['app']}]")
|
||||
f" [{r.get('kind')}/{r.get('app')}]")
|
||||
if a.json:
|
||||
Path(a.json).write_text(json.dumps({"profile": a.profile, "results": results}, indent=2) + "\n")
|
||||
if a.log:
|
||||
|
|
|
|||
573
experiments/postfix-probe/results/mas-profile.json
Normal file
573
experiments/postfix-probe/results/mas-profile.json
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
{
|
||||
"profile": "mas-profile",
|
||||
"results": [
|
||||
{
|
||||
"label": "P2 intake",
|
||||
"sent": "ticket!callback@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket!callback@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "function",
|
||||
"app_status": 201,
|
||||
"app": "created"
|
||||
},
|
||||
{
|
||||
"label": "resource",
|
||||
"sent": "ticket=gs-aktbdkka1q@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=gs-aktbdkka1q@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 200,
|
||||
"app": "correlated"
|
||||
},
|
||||
{
|
||||
"label": "resource + facet",
|
||||
"sent": "ticket=gs-aktbdkka1q+comments@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=gs-aktbdkka1q+comments@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 200,
|
||||
"app": "correlated"
|
||||
},
|
||||
{
|
||||
"label": "extra '=' in id",
|
||||
"sent": "ticket==X1@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket==X1@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "registry function",
|
||||
"sent": "support@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "support@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "function",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "person-style",
|
||||
"sent": "bernd.worsch@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "bernd.worsch@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "opaque",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "upper-case local-part",
|
||||
"sent": "Ticket=gs-aktbdkka1q@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "Ticket=gs-aktbdkka1q@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "percent data",
|
||||
"sent": "ticket=PR%2B42@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR%2B42@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "percent hack form",
|
||||
"sent": "ticket%other.example@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket%other.example@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "opaque",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "bang in resource",
|
||||
"sent": "ticket=gs-aktbdkka1q!web@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=gs-aktbdkka1q!web@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 200,
|
||||
"app": "correlated"
|
||||
},
|
||||
{
|
||||
"label": "facet + interaction",
|
||||
"sent": "ticket=gs-aktbdkka1q+comments!callback@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=gs-aktbdkka1q+comments!callback@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 200,
|
||||
"app": "correlated"
|
||||
},
|
||||
{
|
||||
"label": "reserved $",
|
||||
"sent": "ticket=PR42$4.99@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42$4.99@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "quoted local-part",
|
||||
"sent": "\"ticket=PR42\"@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42@great-service.example",
|
||||
"unchanged": false,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "unknown resource",
|
||||
"sent": "ticket=DOES-NOT-EXIST@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=DOES-NOT-EXIST@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-001",
|
||||
"sent": "bernd.worsch@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "bernd.worsch@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "opaque",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-002",
|
||||
"sent": "anne-marie.schmidt@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "anne-marie.schmidt@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "opaque",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-003",
|
||||
"sent": "support@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "support@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "function",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-004",
|
||||
"sent": "invoice-reviewer_backup@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "invoice-reviewer_backup@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "opaque",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-005",
|
||||
"sent": "happy-helper.v2@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "happy-helper.v2@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "opaque",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-010",
|
||||
"sent": "ticket=PR42@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-011",
|
||||
"sent": "invoice=RE=2026-4711@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "invoice=RE=2026-4711@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "opaque",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-012",
|
||||
"sent": "ticket=PR42+comments@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42+comments@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-013",
|
||||
"sent": "bernd.worsch+github@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "bernd.worsch+github@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "opaque",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-020",
|
||||
"sent": "=PR42@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "=PR42@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-021",
|
||||
"sent": "ticket=@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-022",
|
||||
"sent": "ticket=PR42+@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42+@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-023",
|
||||
"sent": "ticket=PR42+comments+attachments@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42+comments+attachments@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-024",
|
||||
"sent": "ticket=PR42$4.99@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42$4.99@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-025",
|
||||
"sent": "ticket=PR42^1h@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42^1h@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-026",
|
||||
"sent": ".bernd@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "\".bernd\"@great-service.example",
|
||||
"unchanged": false,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-027",
|
||||
"sent": "bernd.@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "\"bernd.\"@great-service.example",
|
||||
"unchanged": false,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-028",
|
||||
"sent": "bernd..worsch@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "\"bernd..worsch\"@great-service.example",
|
||||
"unchanged": false,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-029",
|
||||
"sent": "ticket=PR.42@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR.42@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-030",
|
||||
"sent": "ticket=PR%2B42@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR%2B42@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "corpus p2-001",
|
||||
"sent": "ticket!callback@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket!callback@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "function",
|
||||
"app_status": 201,
|
||||
"app": "created"
|
||||
},
|
||||
{
|
||||
"label": "corpus p2-002",
|
||||
"sent": "ticket=PR42!web@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42!web@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "corpus p2-003",
|
||||
"sent": "ticket=PR42+comments!callback@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42+comments!callback@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "corpus p2-010",
|
||||
"sent": "ticket!@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket!@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p2-011",
|
||||
"sent": "ticket!callback!chat@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket!callback!chat@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p2-012",
|
||||
"sent": "ticket!callback+comments@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket!callback+comments@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p2-013",
|
||||
"sent": "ticket=PR42+comments!callback$4.99@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42+comments!callback$4.99@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p2-014",
|
||||
"sent": "ticket=PR42+comments!call.back@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42+comments!call.back@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-040",
|
||||
"sent": "\"ticket=PR42\"@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "ticket=PR42@great-service.example",
|
||||
"unchanged": false,
|
||||
"mas_valid": true,
|
||||
"kind": "resource",
|
||||
"app_status": 202,
|
||||
"app": "unknown-resource"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-041",
|
||||
"sent": "t\u00efcket=PR42@great-service.example",
|
||||
"smtp": "rejected: 'ascii' codec can't encode character '\\xef' in position 30: ordinal not in range(128)",
|
||||
"arrived_as": null,
|
||||
"unchanged": false,
|
||||
"mas_valid": null,
|
||||
"kind": null,
|
||||
"app_status": null,
|
||||
"app": null
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-042",
|
||||
"sent": "Ticket=PR42@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "Ticket=PR42@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-043",
|
||||
"sent": "my--type=PR42@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "my--type=PR42@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-044",
|
||||
"sent": "type-=PR42@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "type-=PR42@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": false,
|
||||
"kind": null,
|
||||
"app_status": 422,
|
||||
"app": "not-mas"
|
||||
},
|
||||
{
|
||||
"label": "corpus p1-045",
|
||||
"sent": "support-case_v2=PR42@great-service.example",
|
||||
"smtp": "accepted",
|
||||
"arrived_as": "support-case_v2=PR42@great-service.example",
|
||||
"unchanged": true,
|
||||
"mas_valid": true,
|
||||
"kind": "opaque",
|
||||
"app_status": 404,
|
||||
"app": "not-handled"
|
||||
},
|
||||
{
|
||||
"label": "relay attempt",
|
||||
"sent": "x@other.example",
|
||||
"smtp": "rejected: {'x@other.example': (554, b'5.7.1 <x@other.example>: Recipient address rejected: Access denied')}",
|
||||
"arrived_as": null,
|
||||
"unchanged": false,
|
||||
"relay_refused": true
|
||||
},
|
||||
{
|
||||
"label": "relay attempt",
|
||||
"sent": "x!y@other.example",
|
||||
"smtp": "rejected: {'x!y@other.example': (554, b'5.7.1 <x!y@other.example>: Recipient address rejected: Access denied')}",
|
||||
"arrived_as": null,
|
||||
"unchanged": false,
|
||||
"relay_refused": true
|
||||
},
|
||||
{
|
||||
"label": "relay attempt",
|
||||
"sent": "a@other.example@great-service.example",
|
||||
"smtp": "rejected: {'a@other.example@great-service.example': (554, b'5.7.1 <a@other.example@great-service.example>: Recipient address rejected: Access denied')}",
|
||||
"arrived_as": null,
|
||||
"unchanged": false,
|
||||
"relay_refused": true
|
||||
},
|
||||
{
|
||||
"label": "relay attempt",
|
||||
"sent": "a@great-service.example@other.example",
|
||||
"smtp": "rejected: {'a@great-service.example@other.example': (554, b'5.7.1 <a@great-service.example@other.example>: Recipient address rejected: Access denied')}",
|
||||
"arrived_as": null,
|
||||
"unchanged": false,
|
||||
"relay_refused": true
|
||||
},
|
||||
{
|
||||
"label": "relay attempt",
|
||||
"sent": "x%y@other.example",
|
||||
"smtp": "rejected: {'x%y@other.example': (554, b'5.7.1 <x%y@other.example>: Recipient address rejected: Access denied')}",
|
||||
"arrived_as": null,
|
||||
"unchanged": false,
|
||||
"relay_refused": true
|
||||
}
|
||||
]
|
||||
}
|
||||
51
integrations/exim/NOTES.md
Normal file
51
integrations/exim/NOTES.md
Normal 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`).
|
||||
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"
|
||||
62
tests/test_postfix_profile.py
Normal file
62
tests/test_postfix_profile.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""The Postfix integration profile against a real Postfix (MAS-WP-0004 T02).
|
||||
|
||||
Runs only where Docker is available; builds experiments/postfix-probe.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PROBE_DIR = ROOT / "experiments/postfix-probe"
|
||||
|
||||
|
||||
def _docker_ok():
|
||||
if not shutil.which("docker"):
|
||||
return False
|
||||
return subprocess.run(["docker", "info"], capture_output=True).returncode == 0
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(not _docker_ok(), reason="Docker not available")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def probe_results():
|
||||
build = subprocess.run(["docker", "build", "-q", "-t", "mas-postfix-probe", str(PROBE_DIR)],
|
||||
capture_output=True, text=True)
|
||||
if build.returncode != 0:
|
||||
pytest.skip(f"cannot build probe image: {build.stderr[-300:]}")
|
||||
spec = importlib.util.spec_from_file_location("postfix_probe", PROBE_DIR / "probe.py")
|
||||
probe = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(probe)
|
||||
results, _ = probe.run("mas-profile", 2625, corpus=True)
|
||||
return probe, results
|
||||
|
||||
|
||||
def test_never_rewritten(probe_results):
|
||||
# Delivered as sent up to RFC 5321 quoting, or refused at SMTP; never altered.
|
||||
_, results = probe_results
|
||||
for r in results:
|
||||
if "relay_refused" in r or r["smtp"] != "accepted":
|
||||
continue
|
||||
assert r["equivalent"], r
|
||||
if not r["unchanged"] and r["arrived_as"].startswith('"'):
|
||||
assert r["mas_valid"] is False, r # quoting added only to non-MAS forms
|
||||
|
||||
|
||||
def test_p2_forms_delivered(probe_results):
|
||||
_, results = probe_results
|
||||
p2 = [r for r in results if "!" in r["sent"].split("@")[0] and "relay_refused" not in r]
|
||||
assert p2
|
||||
for r in p2:
|
||||
assert r["smtp"] == "accepted" and r["unchanged"], r
|
||||
|
||||
|
||||
def test_not_an_open_relay(probe_results):
|
||||
probe, results = probe_results
|
||||
relay = [r for r in results if "relay_refused" in r]
|
||||
assert [r["sent"] for r in relay] == probe.RELAY_NEGATIVES
|
||||
assert all(r["relay_refused"] for r in relay), relay
|
||||
|
|
@ -4,11 +4,11 @@ type: workplan
|
|||
title: "MAS Postfix integration profile"
|
||||
domain: communication
|
||||
repo: mail-address-semantics
|
||||
status: proposed
|
||||
status: finished
|
||||
owner: claude
|
||||
topic_slug: coulomb-social
|
||||
created: "2026-10-18"
|
||||
updated: "2026-10-18"
|
||||
updated: "2026-10-19"
|
||||
origin: residual
|
||||
origin_ref: MAS-WP-0003
|
||||
state_hub_workstream_id: "bf5eebc0-3b97-5c78-9c3a-58834258744e"
|
||||
|
|
@ -27,7 +27,7 @@ integration profile.
|
|||
|
||||
```task
|
||||
id: MAS-WP-0004-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e1d1c1fb-b34e-5ea3-98e5-10556b80a83b"
|
||||
```
|
||||
|
|
@ -50,7 +50,7 @@ Explain each setting and say why the stock config fails.
|
|||
|
||||
```task
|
||||
id: MAS-WP-0004-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "dcdf491a-3be8-566e-8111-c496a7970b4b"
|
||||
```
|
||||
|
|
@ -64,7 +64,7 @@ suite where Docker is available.
|
|||
|
||||
```task
|
||||
id: MAS-WP-0004-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "a3ddf4f1-6b21-5ee7-8dae-4926fcf4181e"
|
||||
```
|
||||
|
|
@ -72,3 +72,15 @@ state_hub_task_id: "a3ddf4f1-6b21-5ee7-8dae-4926fcf4181e"
|
|||
Check whether Exim has equivalent pitfalls for `!`/`%` local-parts and
|
||||
record the needed settings. Build an Exim profile only if there is
|
||||
demand.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Profile: `integrations/postfix/install.sh` and `README.md`. The live
|
||||
deployment (`deploy/railiance/entrypoint.sh`) now uses it.
|
||||
- Probe: `probe.py --profile mas-profile --corpus`, run in pytest as
|
||||
`tests/test_postfix_profile.py` where Docker is available. Every corpus
|
||||
address arrives unchanged up to RFC 5321 quoting: Postfix adds quotes
|
||||
around non-dot-atoms (`.bernd` becomes `".bernd"`), and those are still
|
||||
invalid MAS. All relay negatives get 554.
|
||||
- Exim: `integrations/exim/NOTES.md`. The stock ACL rejects `!`/`%` for
|
||||
local domains. No profile is built, because there is no demand yet.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue