Files
selfpost/test/e2e/port25_config_check.go
T
mixandClaude Opus 5 23d52bab7c
test / test (push) Has been cancelled
fix: port-25 milter chain, DMARC report validation, auto rate-limit window
Review pass over the 1.4.0-1.9.1 work.

Port 25 no longer inherits main.cf's submission milter chain: smtpd_milters
is always overridden on smtp/inet, to the antispam milter or to nothing.
Without that, OpenDKIM (strict, tempfail) gated relayed mail during a signing
outage, and the journal-milter filed inbound mail into the outbound send log
under the sender's domain -- so Deliveries showed other people's mail and a
forged From: consumed that domain's level-2 budget. architecture.md already
described the intended behaviour; the code now matches.

DMARC reports are checked before they are stored: the policy domain must be a
configured sending domain, and must match the +tag of a per-domain hosted
address. The rua address form is predictable, so anyone able to reach it could
file reports under any domain and evict genuine ones through the retention cap.
Zip attachments are now decompressed as well as gzip.

The auto level-2 ceiling is scaled to the level-1 window before the multiplier
is applied; any window other than 3600s shifted the allowance by window/3600.

Migration 0010 indexes send_log(app_login, created_at) -- both the per-MAIL
FROM rate-limit count and the per-application statistics were scanning the
created_at range.

e2e now regenerates the Postfix config in-container for all four port-25 flag
combinations and requires postfix check to pass; the 1.9.1 crash-loop shipped
in 1.4.0 and survived three releases because only the flags-off path was
exercised.

Migration 0009 stays as shipped -- its conversion branch only fires for a
database carrying application-scope allowed_ips from before 1.9.0. The schema
reference notes that the old list was permissive and the new column is
restrictive; the guide documents the allow-list as a sending control on a
fail-open path, not an authentication boundary.

Also: gofmt across cmd/ and internal/, unused duplicate validators dropped from
internal/web/validate, export no longer swallows store errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:17:39 +03:00

148 lines
4.7 KiB
Go

package e2e
import (
"fmt"
"strings"
)
// port25Case is one combination of the optional port-25 flags, with the
// master.cf `-o` lines its generated smtp/inet stanza must carry.
type port25Case struct {
name string
env []string
want []string
deny []string
}
var port25Cases = []port25Case{
{
name: "inbound relay only",
env: []string{
"INBOUND_RELAY_ENABLE=true",
"DMARC_REPORTS_ENABLE=false",
},
want: []string{
"-o smtpd_sasl_auth_enable=no",
"-o smtpd_relay_restrictions=reject_unauth_destination",
"-o smtpd_recipient_restrictions=reject_unauth_destination,reject_unlisted_recipient",
// No antispam milter configured: the submission chain (strict
// OpenDKIM + journal-milter) must not reach inbound mail.
"-o smtpd_milters=",
},
},
{
name: "dmarc ingest only",
env: []string{
"INBOUND_RELAY_ENABLE=false",
"DMARC_REPORTS_ENABLE=true",
},
want: []string{
"-o smtpd_recipient_restrictions=check_recipient_access texthash:/data/postfix/dmarc_recipients,reject",
"-o smtpd_milters=",
},
},
{
name: "inbound relay and dmarc ingest",
env: []string{
"INBOUND_RELAY_ENABLE=true",
"DMARC_REPORTS_ENABLE=true",
},
want: []string{
"-o smtpd_recipient_restrictions=check_recipient_access texthash:/data/postfix/dmarc_recipients,reject_unauth_destination,reject_unlisted_recipient",
"-o smtpd_milters=",
},
},
{
name: "inbound relay with antispam milter",
env: []string{
"INBOUND_RELAY_ENABLE=true",
"DMARC_REPORTS_ENABLE=false",
"INBOUND_ANTISPAM_MILTER=inet:antispam:11332",
"INBOUND_ANTISPAM_MILTER_ACTION=accept",
},
want: []string{
"-o smtpd_milters={inet:antispam:11332,default_action=accept}",
},
deny: []string{"opendkim.sock", "journal.sock"},
},
}
// checkPort25ConfigCombinations regenerates the Postfix configuration inside
// the running container for every combination of the optional port-25 flags
// and asserts `postfix check` still passes.
//
// This is the regression guard for the 1.9.1 crash-loop: enabling inbound
// relay or DMARC ingest made postfix-config.sh fail at start (postconf -P
// cannot set master.cf -o values containing whitespace), and nothing in the
// suite exercised the enabled path — the stand runs with both flags off, so
// the bug shipped in 1.4.0 and survived three releases.
//
// Only configuration *generation* is exercised: the script is re-run with
// different environment variables and its output inspected. Postfix is not
// reloaded, so the running master keeps the config it started with, and the
// final pass restores the stand's own (flags-off) configuration on disk.
func checkPort25ConfigCombinations(s *stack) error {
for _, tc := range port25Cases {
if err := runPostfixConfig(s, tc.env); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
master, err := s.execIn("selfpost", "cat", "/etc/postfix/master.cf")
if err != nil {
return fmt.Errorf("%s: read master.cf: %v\n%s", tc.name, err, master)
}
stanza := smtpInetStanza(master)
if stanza == "" {
return fmt.Errorf("%s: no smtp/inet service in master.cf:\n%s", tc.name, master)
}
for _, want := range tc.want {
if !strings.Contains(stanza, want) {
return fmt.Errorf("%s: smtp/inet is missing %q:\n%s", tc.name, want, stanza)
}
}
for _, deny := range tc.deny {
if strings.Contains(stanza, deny) {
return fmt.Errorf("%s: smtp/inet unexpectedly carries %q:\n%s", tc.name, deny, stanza)
}
}
}
// Back to the stand's own configuration: flags off, no port-25 listener.
if err := runPostfixConfig(s, []string{"INBOUND_RELAY_ENABLE=false", "DMARC_REPORTS_ENABLE=false"}); err != nil {
return fmt.Errorf("restore flags-off configuration: %w", err)
}
return checkInboundRelayOff(s)
}
// runPostfixConfig re-runs the config generator with env overrides. The script
// ends with `postfix check`, so a non-zero exit is exactly the start-time
// failure an operator would hit.
func runPostfixConfig(s *stack, env []string) error {
args := append([]string{"env"}, env...)
args = append(args, "/usr/local/bin/postfix-config.sh")
out, err := s.execIn("selfpost", args...)
if err != nil {
return fmt.Errorf("postfix-config.sh with %v failed: %v\n%s", env, err, out)
}
return nil
}
// smtpInetStanza returns the smtp/inet service line and its -o options.
func smtpInetStanza(master string) string {
var b strings.Builder
in := false
for _, line := range strings.Split(master, "\n") {
switch {
case strings.HasPrefix(line, "smtp ") || strings.HasPrefix(line, "smtp\t"):
in = true
case in && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")):
case in:
return b.String()
default:
continue
}
b.WriteString(line)
b.WriteString("\n")
}
return b.String()
}