feat: implement C.4 — hermetic container e2e gate + native release build

Separate test/e2e Go module drives the shipped deploy/docker-compose.yml
(plus a test-only override: self-signed cert, low ports, isolated compose
project) against a fake DNS zone (CoreDNS) and an smtp-sink MX, exactly as
an administrator and their applications would over HTTP/SMTP — covering the
class of failure unit tests can't see (container wiring). Positive path:
setup -> login -> domain -> DKIM record published into the fake zone ->
application -> SMTP AUTH send -> DKIM verified against the DNS-published
key -> send-log queued->sent. Negative: no-AUTH/unauthenticated relay,
sender/login mismatch, L1 (anvil) and L2 (panel) rate limits, journal-milter
fail-open, SELFPOST_HOSTNAME gate, session survives docker restart.

release.yml moves off qemu to a native per-arch build (amd64/arm64), each
gated by this suite before its tag is pushed and merged into the version
manifest.

Verified green on selfpost.mixfed.ru via `make e2e`; go vet/gofmt clean in
both modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 00:29:14 +03:00
parent 3b0a71cf11
commit c8b067a686
21 changed files with 1674 additions and 17 deletions
+142
View File
@@ -0,0 +1,142 @@
package e2e
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"time"
)
// smtpsAddr is where compose.override.yml publishes the primary implicit-TLS
// submission port (465 in the shipped compose, remapped here to stay off the
// dev server's real ports).
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.
type sendAttempt struct {
authLogin, authPassword string
from, to 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
}
// 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 a bare TCP connect
// succeeds. 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.
func waitForSMTPSReady(timeout time.Duration) error {
return waitFor("smtps port to accept connections", timeout, 300*time.Millisecond, func() (bool, error) {
conn, err := net.DialTimeout("tcp", smtpsAddr, 2*time.Second)
if err != nil {
return false, err
}
_ = conn.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 {
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,
)
}