test / test (push) Canceled after 0s
Technical review (Opus) and security review (Fable) of 9efc9c7..a59e3d4; reports in the session's scratchpad. Fixed: - B1 (verified bug): store.UpdateStatus read before writing in its transaction; a commit by another connection in between (the dmarc-ingest process opens the same file) failed it at once with "database is locked" and the delivery line was lost. The first statement is now a write. - R3: a delivery line updated every row of its queue id and recipient; it now updates only the newest (Postfix reuses short ids). The sweep closes by row id (QueuedDelivery.ID, CloseUnreported(id)). - R4: a contradicted sweep keeps a mark, reason "reconcile-contradicted", instead of losing which row it was. - R1: contradicted cannot see a lost line (never read late). New counter reconcile_missed: at close, the sweep looks for the row's delivery line in mail.log and mail.log.1 (QueueLines). 0007 edited - it has run on no database but throwaway stands. - R2: queued_over_lifetime cannot measure "what removing the sweep would leave" while the sweep runs; its text now says rows stuck despite the sweep, and the rule uses closed - missed - contradicted. - cert-reload (R5, R6, R7, security medium/low): both files copied to .new and the copies checked as a pair before both are renamed; acts on the copy only when postconf says Postfix reads it; a failed reload is retried next pass; umask 077; says so when the mounted files cannot be read. The e2e case asserts Postfix reads the copy, the copy equals the mount, the key is 600 root. - Security low: a recipient with a quoted local part - the journal cut "a b"@x at the space, deliveryRe cut "a>b"@x at the ">" - matched no delivery line and was closed by the sweep. cleanAddress ends a bracketed address at the first ">" outside quotes; deliveryRe takes the recipient up to the ">, " before relay= (test fixtures given the relay= every real delivery line has). - Security low: postqueue -p's error carries at most 512 bytes of output. - Nits: addDeliveryLogDay refuses a column not on its list; the truncation test checks its message. - To stage 2b (page wording, mockup-first): the history step of a bounced row says the receiving server refused it, false for a row the sweep closed. To the owner when stage 2b is reached: since M13 the "deferred" figure counts whole messages too, which "Connections turned away" would not name. Found while verifying: the e2e's main positive path failed once with "553 not owned by user app1" - an smtpd idle from before the panel's reload took the connection in the reload's second. Measured on the stand (one-off probe, not committed): sending 0, 10, 40, 110 s after the save (two seconds at the soonest) always met an smtpd started after the reload. The e2e now waits for the sender map after each application it creates (waitForSenderMap: AUTH, MAIL, RCPT, RSET - no message, no row), and the level-1 case uses it instead of its own loop. Docs: architecture § Queue reconcile, schema-migrations (rows 7 and 8, chain head 8), CHANGELOG, audit plan (stage 1 note, the measured window, a new stage 2b line: 62 lines), roadmap 15/62. Evidence on the test server for this tree: go vet clean, go test ./... 23 packages ok, make e2e exit 0 (renewed certificate, hung panel, level 1 at 49/60). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
286 lines
9.1 KiB
Go
286 lines
9.1 KiB
Go
package e2e
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/smtp"
|
|
"net/textproto"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// smtpsAddr is where compose.override.yml publishes the primary implicit-TLS
|
|
// submission port (465 in the shipped compose, remapped here to avoid clashing
|
|
// with a local deployment on the same host).
|
|
const smtpsAddr = "127.0.0.1:20465"
|
|
|
|
// sendAttempt is one SMTP transaction against the stand. An empty authLogin
|
|
// means "connect without AUTH" (negative checks 1/3); from/to are full
|
|
// mailbox addresses. headerFrom is the From: line of the message when it is
|
|
// not to be the envelope sender (the sender check holds the two to one rule).
|
|
type sendAttempt struct {
|
|
authLogin, authPassword string
|
|
from, to string
|
|
headerFrom string
|
|
subject, body string
|
|
}
|
|
|
|
// sendResult records the error (if any) at each stage the transaction
|
|
// reached. Only the fields up to and including the first failure are set —
|
|
// the rest are nil because that stage was never attempted, not because it
|
|
// succeeded silently.
|
|
type sendResult struct {
|
|
dialErr error
|
|
authErr error
|
|
mailErr error
|
|
rcptErr error
|
|
dataErr error
|
|
}
|
|
|
|
// ok reports whether the message was fully accepted (DATA closed cleanly).
|
|
func (r sendResult) ok() bool {
|
|
return r.dialErr == nil && r.authErr == nil && r.mailErr == nil && r.rcptErr == nil && r.dataErr == nil
|
|
}
|
|
|
|
// firstErr returns whichever stage failed first, or nil if none did.
|
|
func (r sendResult) firstErr() error {
|
|
for _, e := range []error{r.dialErr, r.authErr, r.mailErr, r.rcptErr, r.dataErr} {
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// refusedAt says nil only when the transaction was refused at stage
|
|
// ("connect", "AUTH", "MAIL", "RCPT" or "DATA") with the server's reply code
|
|
// and a reply text containing text. A negative case passes on its own refusal
|
|
// and nothing else: a reset or a timeout while Postfix was still starting once
|
|
// passed for the level-1 rate limit. Anything else is described for the
|
|
// failure message - where it stopped and what came.
|
|
func (r sendResult) refusedAt(stage string, code int, text string) error {
|
|
for _, s := range []struct {
|
|
name string
|
|
err error
|
|
}{{"connect", r.dialErr}, {"AUTH", r.authErr}, {"MAIL", r.mailErr}, {"RCPT", r.rcptErr}, {"DATA", r.dataErr}} {
|
|
if s.err == nil {
|
|
continue
|
|
}
|
|
if s.name != stage {
|
|
return fmt.Errorf("stopped at %s, not at %s: %v", s.name, stage, s.err)
|
|
}
|
|
return replyIs(s.err, code, text)
|
|
}
|
|
return fmt.Errorf("accepted, want refused at %s with %d", stage, code)
|
|
}
|
|
|
|
// replyIs says nil when err is the server's reply code with text in it.
|
|
func replyIs(err error, code int, text string) error {
|
|
var reply *textproto.Error
|
|
if !errors.As(err, &reply) {
|
|
return fmt.Errorf("no SMTP reply but %v, want %d ...%s", err, code, text)
|
|
}
|
|
if reply.Code != code || !strings.Contains(reply.Msg, text) {
|
|
return fmt.Errorf("reply %d %s, want %d ...%s", reply.Code, reply.Msg, code, text)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// attemptSend drives one SMTP transaction over the implicit-TLS port, exactly
|
|
// as an application client library would: TLS connect, EHLO, optional AUTH
|
|
// PLAIN, MAIL/RCPT/DATA. The certificate is the stand's throwaway self-signed
|
|
// one (see stage.go), so verification is skipped — Postfix's
|
|
// smtpd_tls_security_level is "may" here just like production, this is purely
|
|
// about the test client trusting an unknown CA.
|
|
func attemptSend(a sendAttempt) sendResult {
|
|
var res sendResult
|
|
|
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
|
conn, err := tls.DialWithDialer(dialer, "tcp", smtpsAddr, &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
ServerName: selfpostHostname,
|
|
})
|
|
if err != nil {
|
|
res.dialErr = fmt.Errorf("dial %s: %w", smtpsAddr, err)
|
|
return res
|
|
}
|
|
c, err := smtp.NewClient(conn, selfpostHostname)
|
|
if err != nil {
|
|
res.dialErr = fmt.Errorf("smtp handshake: %w", err)
|
|
_ = conn.Close()
|
|
return res
|
|
}
|
|
defer c.Close()
|
|
|
|
if err := c.Hello("e2e-client.e2e.test"); err != nil {
|
|
res.dialErr = err
|
|
return res
|
|
}
|
|
|
|
if a.authLogin != "" {
|
|
auth := smtp.PlainAuth("", a.authLogin, a.authPassword, selfpostHostname)
|
|
if err := c.Auth(auth); err != nil {
|
|
res.authErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
}
|
|
|
|
if err := c.Mail(a.from); err != nil {
|
|
res.mailErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
if err := c.Rcpt(a.to); err != nil {
|
|
res.rcptErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
w, err := c.Data()
|
|
if err != nil {
|
|
res.dataErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
msg := buildMessage(a)
|
|
if _, err := w.Write([]byte(msg)); err != nil {
|
|
res.dataErr = err
|
|
} else if err := w.Close(); err != nil {
|
|
res.dataErr = err
|
|
}
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
|
|
// waitForSMTPSReady polls the implicit-TLS port until Postfix greets over
|
|
// TLS. Used after a container (re)start: the panel's HTTP port and Postfix's
|
|
// smtpd come up independently (postfix-wrapper.sh additionally waits on both
|
|
// milter sockets before starting Postfix at all), so a caller that only
|
|
// waited for the panel could still race a not-yet-listening smtpd. A bare TCP
|
|
// connect is not enough: Docker's port proxy accepts on the host side before
|
|
// anything listens in the container and then resets the connection, which is
|
|
// how the level-1 rate-limit case once took a reset for its limit.
|
|
func waitForSMTPSReady(timeout time.Duration) error {
|
|
return waitFor("smtps port to greet", timeout, 300*time.Millisecond, func() (bool, error) {
|
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", smtpsAddr, &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
ServerName: selfpostHostname,
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
c, err := smtp.NewClient(conn, selfpostHostname) // reads the 220 greeting
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return false, err
|
|
}
|
|
_ = c.Quit()
|
|
_ = c.Close()
|
|
return true, nil
|
|
})
|
|
}
|
|
|
|
// buildMessage renders a minimal, valid RFC 5322 message. subject is expected
|
|
// to carry a unique token so the harness can find this exact message in the
|
|
// sink-MX's dump directory afterwards.
|
|
func buildMessage(a sendAttempt) string {
|
|
if a.headerFrom != "" {
|
|
a.from = a.headerFrom
|
|
}
|
|
return fmt.Sprintf(
|
|
"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n",
|
|
a.from, a.to, a.subject, time.Now().UTC().Format(time.RFC1123Z), a.body,
|
|
)
|
|
}
|
|
|
|
// mailTwiceOnOneConnection authenticates once and sends MAIL FROM twice on the
|
|
// same connection, with a RSET between, and returns how each was answered. No
|
|
// message is sent: this is about what the server says to the second
|
|
// transaction of a connection whose first was refused.
|
|
func mailTwiceOnOneConnection(login, password, from string) [2]error {
|
|
var out [2]error
|
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", smtpsAddr, &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
ServerName: selfpostHostname,
|
|
})
|
|
if err != nil {
|
|
return [2]error{nil, nil}
|
|
}
|
|
c, err := smtp.NewClient(conn, selfpostHostname)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return [2]error{nil, nil}
|
|
}
|
|
defer c.Close()
|
|
if err := c.Hello("e2e-client.e2e.test"); err != nil {
|
|
return [2]error{nil, nil}
|
|
}
|
|
if err := c.Auth(smtp.PlainAuth("", login, password, selfpostHostname)); err != nil {
|
|
return [2]error{nil, nil}
|
|
}
|
|
out[0] = c.Mail(from)
|
|
_ = c.Reset()
|
|
out[1] = c.Mail(from)
|
|
_ = c.Quit()
|
|
return out
|
|
}
|
|
|
|
// waitForSenderMap waits until Postfix lets login use from: an application
|
|
// the panel has just created is written into the sender map and Postfix is
|
|
// reloaded, and an smtpd that was idle from before the reload can still take
|
|
// a connection in the second that follows and answer 553 "not owned by user"
|
|
// (seen on the stand; two seconds after the save it no longer happened, at 0,
|
|
// 10, 40 or 110 s of waiting). It goes as far as RCPT and then RSET, so no
|
|
// message is sent and no row is written. Only that 553 is waited out, for 15
|
|
// s at most; any other answer fails at once.
|
|
func waitForSenderMap(login, password, from string) error {
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
for {
|
|
err := probeSender(login, password, from)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if replyIs(err, 553, "not owned by user "+login) != nil || time.Now().After(deadline) {
|
|
return fmt.Errorf("Postfix does not yet let %s send as %s: %w", login, from, err)
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// probeSender authenticates as login and offers MAIL FROM from and one RCPT,
|
|
// then resets. It returns the first refusal, or nil.
|
|
func probeSender(login, password, from string) error {
|
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", smtpsAddr, &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
ServerName: selfpostHostname,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
|
c, err := smtp.NewClient(conn, selfpostHostname)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return err
|
|
}
|
|
defer c.Close()
|
|
if err := c.Hello("e2e-client.e2e.test"); err != nil {
|
|
return err
|
|
}
|
|
if err := c.Auth(smtp.PlainAuth("", login, password, selfpostHostname)); err != nil {
|
|
return err
|
|
}
|
|
if err := c.Mail(from); err != nil {
|
|
return err
|
|
}
|
|
if err := c.Rcpt(recipient); err != nil {
|
|
return err
|
|
}
|
|
_ = c.Reset()
|
|
_ = c.Quit()
|
|
return nil
|
|
}
|