Comments only, no behaviour changed. Each main.cf/master.cf parameter the
build sets, the wrapper's and the reload hook's reliance on Postfix 3.7.11
(postfix-script, master.5, postconf.5, cleanup_init.c, smtpd_peer.c,
README_FILES), supervisor 4.2.5 (process.py, options.py, events.rst),
logrotate 3.21.0, OpenDKIM's config, Cyrus SASL's sasldb, rspamd 3.11.1
(milter_headers.lua, multimap, the map watcher), Docker Compose, CoreDNS
and certbot now say where that is written. Measurements already there stay
measurements ("date not recorded" where none was); hand-composed fixtures
say so.
Contradictions left for their own commits: C-P31 cleanup's size limit,
C-P42 master outside supervisor's signal, C-V5/V6 startsecs, C-V10,
C-S3/Q3 sasldb written in place, C-Q4/A1 certbot's symlinks, C-R7 rspamd
skips local clients but the signature routine does not, C-P8, C-R4, C-R12,
C-M7, C-E2, C-K8/K9, C-D1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
9.5 KiB
Go
231 lines
9.5 KiB
Go
package e2e
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testLevel2RateLimit is plan C.4 negative check 5: a limit set through the
|
|
// panel (not the environment) rejects the message that exceeds it, and the
|
|
// rejection is visible in the send log — the panel -> DB -> milter path.
|
|
func testLevel2RateLimit(t *testing.T, sc *scenario) {
|
|
ip, err := lastSMTPClientIP(h)
|
|
if err != nil {
|
|
t.Fatalf("determine observed SMTP client IP: %v", err)
|
|
}
|
|
sc.clientIP = ip
|
|
|
|
login, password, err := sc.panel.addApplication(sc.domainID, "l2app", "wildcard", "")
|
|
if err != nil {
|
|
t.Fatalf("add application: %v", err)
|
|
}
|
|
if err := waitForSenderMap(login, password, "billing@"+senderDomain); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sc.l2AppLogin, sc.l2AppPassword = login, password
|
|
|
|
appID, err := sc.panel.applicationID(sc.domainID, login)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sc.panel.setApplicationLimit(sc.domainID, appID, 1, 3600); err != nil {
|
|
t.Fatalf("save application rate limit: %v", err)
|
|
}
|
|
|
|
from := "billing@" + senderDomain
|
|
|
|
first := attemptSend(sendAttempt{
|
|
authLogin: login, authPassword: password,
|
|
from: from, to: recipient, subject: "e2e " + uniqueToken("l2-first"), body: "ok",
|
|
})
|
|
if !first.ok() {
|
|
t.Fatalf("first message under the level-2 limit was rejected: %v", first.firstErr())
|
|
}
|
|
|
|
second := attemptSend(sendAttempt{
|
|
authLogin: login, authPassword: password,
|
|
from: from, to: recipient, subject: "e2e " + uniqueToken("l2-second"), body: "should be rejected",
|
|
})
|
|
// The milter answers tempfail at MAIL FROM, which Postfix words as below
|
|
// (Postfix 3.7.11, src/milter/milter8.c, milter8_event(), SMFIR_TEMPFAIL:
|
|
// "451 4.7.1 Service unavailable - try again later").
|
|
if err := second.refusedAt("MAIL", 451, "4.7.1 Service unavailable"); err != nil {
|
|
t.Fatalf("the second message over the level-2 limit: %v", err)
|
|
}
|
|
|
|
// The limit holds for every message of a connection, not for its first
|
|
// only. The milter library hands the milter a fresh session after any reply
|
|
// that is not "continue" - the refusal above is one - and Postfix does not
|
|
// introduce the client again, so a second MAIL FROM on the same connection
|
|
// used to arrive with no client address and was let through (found by the
|
|
// technical review of 2026-10-12; the same hole let an application past its
|
|
// client-address list). Postfix's half: 3.7.11, src/smtpd/smtpd.c,
|
|
// smtpd_proto(), calls milter_conn_event() once per connection.
|
|
errs := mailTwiceOnOneConnection(login, password, from)
|
|
for i, err := range errs {
|
|
if err := replyIs(err, 451, "4.7.1 Service unavailable"); err != nil {
|
|
t.Fatalf("over the level-2 limit, MAIL FROM %d of one connection: %v", i+1, err)
|
|
}
|
|
}
|
|
|
|
if err := waitFor("a rate-limited row for l2app in the send log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
|
|
rows, err := sc.panel.sendLogRows(senderDomain, login)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if containsCell(rows, "rate-limited") {
|
|
return true, nil
|
|
}
|
|
return false, fmt.Errorf("no rate-limited row for l2app yet")
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// testSenderLoginMismatch is plan C.4 negative check 2: an authenticated
|
|
// application cannot send as a sender it does not own (architecture.md § Mail
|
|
// path, reject_sender_login_mismatch) — the core anti-spoofing control.
|
|
func testSenderLoginMismatch(t *testing.T, sc *scenario) {
|
|
res := attemptSend(sendAttempt{
|
|
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
|
from: "someone@not-" + senderDomain, to: recipient,
|
|
subject: "e2e " + uniqueToken("mismatch"), body: "should be rejected",
|
|
})
|
|
// Postfix evaluates smtpd_sender_restrictions with smtpd_delay_reject=yes
|
|
// (the default): the mismatch is detected at MAIL FROM but the reject is
|
|
// only sent back at RCPT TO. (Postfix 3.7.11, src/global/mail_params.h,
|
|
// DEF_SMTPD_DELAY_REJECT 1; src/smtpd/smtpd_check.c,
|
|
// reject_auth_sender_login_mismatch(): 553 5.7.1 "Sender address rejected:
|
|
// not owned by user".)
|
|
if err := res.refusedAt("RCPT", 553, "5.7.1"); err != nil {
|
|
t.Fatalf("a sender the application does not own: %v", err)
|
|
}
|
|
if err := replyIs(res.rcptErr, 553, "Sender address rejected: not owned by user"); err != nil {
|
|
t.Fatalf("a sender the application does not own: %v", err)
|
|
}
|
|
}
|
|
|
|
// testNoAuthRejected is plan C.4 negative check 1: no SASL session, no mail.
|
|
// The sender address used here belongs to no registered domain, so the
|
|
// rejection is unambiguously about the missing AUTH and not sender ownership.
|
|
// The reply: smtps's client restrictions end in "reject" (build/
|
|
// postfix-config.sh), Postfix 3.7.11, src/smtpd/smtpd_check.c,
|
|
// generic_checks(): var_reject_code (554) "<%s>: %s rejected: Access denied",
|
|
// delayed to RCPT by smtpd_delay_reject.
|
|
func testNoAuthRejected(t *testing.T, sc *scenario) {
|
|
res := attemptSend(sendAttempt{
|
|
from: "anyone@unregistered.e2e.test", to: recipient,
|
|
subject: "e2e " + uniqueToken("noauth"), body: "should be rejected",
|
|
})
|
|
if err := res.refusedAt("RCPT", 554, "Client host rejected: Access denied"); err != nil {
|
|
t.Fatalf("an unauthenticated send: %v", err)
|
|
}
|
|
}
|
|
|
|
// testForeignRelayRejected is plan C.4 negative check 3: a direct proof that
|
|
// the submission port is not an open relay — even the exact recipient the
|
|
// positive path just delivered to is refused without AUTH. The refusal is
|
|
// smtpd_client_restrictions (permit_sasl_authenticated, reject), which on 465
|
|
// and 587 comes before reject_unauth_destination, so the reply is the same as
|
|
// check 1's; reject_unauth_destination's own "Relay access denied" is held on
|
|
// port 25 (testInboundRelay), where clients do not authenticate.
|
|
func testForeignRelayRejected(t *testing.T, sc *scenario) {
|
|
res := attemptSend(sendAttempt{
|
|
from: "anyone@unregistered.e2e.test", to: recipient,
|
|
subject: "e2e " + uniqueToken("relay"), body: "should be rejected",
|
|
})
|
|
if err := res.refusedAt("RCPT", 554, "Client host rejected: Access denied"); err != nil {
|
|
t.Fatalf("an unauthenticated relay to an external destination: %v", err)
|
|
}
|
|
}
|
|
|
|
// checkContainerAlive confirms the container is still up after a component
|
|
// stop — the crashexit listener must not have brought it down.
|
|
func checkContainerAlive(s *stack) error {
|
|
out, err := s.execIn("selfpost", "true")
|
|
if err != nil {
|
|
return fmt.Errorf("container not responding to exec: %v (%s)", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// testSessionSurvivesRestart is plan C.4 negative/regression check 8 (moved
|
|
// here from the manual B.1 stand check, plan item C.4's closing note): the
|
|
// login session (SQLite-backed, plan B.1) must survive `docker restart`.
|
|
func testSessionSurvivesRestart(t *testing.T, sc *scenario) {
|
|
if err := h.restartSelfpost(); err != nil {
|
|
t.Fatalf("restart selfpost: %v", err)
|
|
}
|
|
if err := waitFor("panel HTTP to answer after restart", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
|
_, err := sc.panel.status()
|
|
return err == nil, err
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The panel and Postfix come up independently after a restart (see
|
|
// waitForSMTPSReady) — the next subtest sends mail, so make sure smtpd is
|
|
// actually listening before this one returns.
|
|
if err := waitForSMTPSReady(30 * time.Second); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp, err := sc.panel.status()
|
|
if err != nil {
|
|
t.Fatalf("GET /overview after restart: %v", err)
|
|
}
|
|
if resp.Request.URL.Path != "/overview" {
|
|
t.Fatalf("session did not survive restart: landed on %s instead of /overview", resp.Request.URL.Path)
|
|
}
|
|
}
|
|
|
|
// testLevel1RateLimit is plan C.4 negative check 4: the native Postfix anvil
|
|
// backstop (smtpd_client_message_rate_limit, guide § Rate limiting), set by
|
|
// the override to RATE_LIMIT_MESSAGES_PER_IP=50, rejects once exceeded. It
|
|
// uses a dedicated application with no level-2 limit of its own, and retries
|
|
// well past that count, so the result is unambiguous regardless of how much of
|
|
// the shared per-IP budget earlier subtests already spent (they stay well
|
|
// under 50 between them).
|
|
//
|
|
// Only anvil's own reply counts, and only after at least one message of the
|
|
// loop went through: the case once passed on "limit tripped on attempt 1/60"
|
|
// when the connection was reset because Postfix was still starting - and,
|
|
// held to anvil's reply, it turned out the first message had always been
|
|
// refused for another reason: the new login was not yet in the sender map an
|
|
// smtpd from before the panel's reload held (waitForSenderMap). (An incident
|
|
// on the stand, date not recorded.) Anvil's reply: Postfix 3.7.11,
|
|
// src/smtpd/smtpd.c, mail_cmd(): "450 4.7.1 Error: too much mail from %s".
|
|
func testLevel1RateLimit(t *testing.T, sc *scenario) {
|
|
login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "")
|
|
if err != nil {
|
|
t.Fatalf("add application: %v", err)
|
|
}
|
|
send := func() sendResult {
|
|
return attemptSend(sendAttempt{
|
|
authLogin: login, authPassword: password,
|
|
from: "l1@" + senderDomain, to: recipient,
|
|
subject: "e2e " + uniqueToken("l1"), body: "rate limit probe",
|
|
})
|
|
}
|
|
|
|
if err := waitForSenderMap(login, password, "l1@"+senderDomain); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res := send(); !res.ok() {
|
|
t.Fatalf("the first message of %s, before any limit could trip: %v", login, res.firstErr())
|
|
}
|
|
|
|
const maxAttempts = 60
|
|
for i := 1; i < maxAttempts; i++ {
|
|
res := send()
|
|
if res.ok() {
|
|
continue
|
|
}
|
|
if err := res.refusedAt("MAIL", 450, "4.7.1 Error: too much mail from"); err != nil {
|
|
t.Fatalf("attempt %d/%d: %v", i+1, maxAttempts, err)
|
|
}
|
|
t.Logf("level-1 limit tripped on attempt %d/%d: %v", i+1, maxAttempts, res.firstErr())
|
|
return
|
|
}
|
|
t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=50) never tripped after %d sends", maxAttempts)
|
|
}
|