Files
selfpost/test/e2e/smtp_client.go
T
mixandClaude Opus 5.5 c2f63d2789
test / test (push) Waiting to run
panel-dkim-signing: the queue by name, and findings of the reviews
The first rollout of faa3c11 to the test server crash-looped: without
opendkim the image's package accounts got other numbers (postfix 101 ->
100, postdrop 103 -> 102), the queue under /data kept the old ones, and
postsuper, which "postfix check" runs as the mail owner
(src/postsuper/postsuper.c:1442), could not open it. Rolled back within
five minutes. The entrypoint now hands an existing queue to Postfix's users
by name before postfix-config.sh, and again after it (own_queue).

Technical review (F1-F10):
- F1 high: a backup of an upgraded installation carried /data/opendkim, a
  restore left it root's, and the panel refused every start. The tree is
  left out of archives, and the entrypoint makes it the panel's alone
  (0700/0600; also the security review's F3 - nothing of 2.8 needs the
  group any more; a 2.7.x start re-normalises it).
- F2: List-Unsubscribe-Post is signed (RFC 8058); Sender too (security F1).
- F3: SetServerName keeps the name in lower case - a key written under
  "Mail.Example.com" was never found. Only the DKIM side: the SASL realm
  is compared as written.
- F4: the signer rewrites the selector table at every start
  (WriteSelectors, replacing EnsureServerSigned), and tries again every
  10 s while a rewrite fails.
- F5: e2e signature_over_awkward_headers (folded, tab-led, no space, two
  To:, 8-bit body, List-Unsubscribe-Post) checked by dkimpy;
  TestStartsOnA27Data starts on a 2.7 /data (old key tree, a queue of
  foreign numbers) and checks the copied key signs, the old tree's modes
  and the queue's owners; unit tests for repeated headers and the header
  limit.
- F6: the table's writers are serialised (tableMu).
- F7: the table is read for every message; keys the table no longer names
  are dropped from memory.
- F8: the signature's value brings its own space (SMFIP_HDR_LEADSPC gets
  none from Postfix, cleanup_milter.c:1085).
- F9: a body line over 998 bytes is logged with its domain: Postfix's SMTP
  client breaks it after signing (smtp_proto.c:1266-1274).
- F10: the keys are copied while /data/dkim holds none (not only when it
  is absent), after the old tree is made the panel's (an install from 2.6
  had another uid); the plan's rollback note says what a rollback keeps.

Security review: F2 a header section over 1 MiB is refused (552 5.3.4)
and the kept headers freed once the block is built - fifty connections of
header-only messages could take the panel down; F5 the shown headers a
message has (Subject, Date, To, Cc, Reply-To, Sender) are over-signed,
only when present, since cleanup adds a missing Date: after the milters.
F1 (holding Sender: to the login like From:) changes what is refused and
waits for the owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:44:59 +03:00

300 lines
9.8 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
moreTo []string // further RCPT TO, after to
headerFrom string
subject, body string
// raw, when set, is the whole message as written - headers, the empty
// line and the body - in place of buildMessage's.
raw 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
}
for _, to := range append([]string{a.to}, a.moreTo...) {
if err := c.Rcpt(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 a.raw != "" {
msg = a.raw
}
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 (seen on the
// stand, date not recorded; Docker's documentation does not describe it).
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, date not recorded; two seconds after the save it no
// longer happened, at 0, 10, 40 or 110 s of waiting). Postfix 3.10.13: an smtpd
// reads the texthash map once, when it starts (html/DATABASE_README.html,
// "texthash"), and one from before the reload exits only when it next sees
// master's status pipe closed (src/master/multi_server.c,
// multi_server_abort()). 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("not yet: Postfix does not 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
}