fix: port-25 milter chain, DMARC report validation, auto rate-limit window
test / test (push) Has been cancelled
test / test (push) Has been cancelled
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>
This commit is contained in:
@@ -108,6 +108,9 @@ func TestE2E(t *testing.T) {
|
||||
if err := checkInboundRelayOff(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkPort25ConfigCombinations(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkLogrotateConfigMode(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user