test / test (push) Waiting to run
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.
Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.
Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.
Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.
KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.
e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
429 lines
13 KiB
Go
429 lines
13 KiB
Go
// Package e2e is the hermetic container e2e gate: it drives the shipped
|
|
// deploy/docker-compose.yml (plus a test-only override) exactly as an
|
|
// administrator and their applications would, so the class of failure unit
|
|
// tests cannot see — broken container wiring — has one place to be caught
|
|
// before an image is published.
|
|
//
|
|
// It is a separate module on purpose (see ../../docs/development.md):
|
|
// `go test ./...` in the main module never pulls this in, and its test-only
|
|
// dependencies (DKIM verification) never enter the shipped binaries' build
|
|
// graph.
|
|
package e2e
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var statusCellPattern = regexp.MustCompile(`<span class="tag[^"]*">(queued|sent|deferred|bounced|rejected)</span>`)
|
|
|
|
// h is the single shared stand for the whole ordered scenario in TestE2E.
|
|
// TestHostnameGate does not use it — it spins its own disposable container.
|
|
var h *stack
|
|
|
|
func TestMain(m *testing.M) {
|
|
s, err := newStack()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "e2e: build stack:", err)
|
|
os.Exit(1)
|
|
}
|
|
h = s
|
|
|
|
if err := prepareStage(s); err != nil {
|
|
fmt.Fprintln(os.Stderr, "e2e: prepare stage:", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := s.build(); err != nil {
|
|
fmt.Fprintln(os.Stderr, "e2e: build:", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := s.up(); err != nil {
|
|
fmt.Fprintln(os.Stderr, "e2e: up:", err)
|
|
s.down()
|
|
os.Exit(1)
|
|
}
|
|
|
|
code := m.Run()
|
|
|
|
if code != 0 {
|
|
for _, svc := range []string{"selfpost", "coredns", "sink"} {
|
|
fmt.Fprintf(os.Stderr, "\n==== logs: %s ====\n%s\n", svc, s.logs(svc))
|
|
}
|
|
}
|
|
// Panel/postfix-owned files under the /data bind mount outlive the
|
|
// container; reclaim ownership while selfpost is still up so a later
|
|
// prepareStage RemoveAll (or a local re-run) is not stuck on EACCES.
|
|
s.reclaimData()
|
|
s.down()
|
|
os.Exit(code)
|
|
}
|
|
|
|
// The one administrator of the run.
|
|
const (
|
|
adminUser = "e2eadmin"
|
|
adminPassword = "correct-horse-battery-staple"
|
|
)
|
|
|
|
// scenario carries state forward between the ordered subtests of TestE2E:
|
|
// each later step needs something an earlier one produced (the domain id, the
|
|
// DKIM record, the application credentials, an observed client IP).
|
|
type scenario struct {
|
|
panel *panelClient
|
|
|
|
domainID string
|
|
dkimName string
|
|
dkimValue string
|
|
dkimZoneRelName string // dkimName without the trailing ".e2e.test."
|
|
appLogin string
|
|
appPassword string
|
|
l2AppLogin string
|
|
l2AppPassword string
|
|
clientIP string
|
|
zoneRecords []txtRecord
|
|
// deliveryPage is the panel page of the first delivered message.
|
|
deliveryPage string
|
|
}
|
|
|
|
func TestE2E(t *testing.T) {
|
|
sc := &scenario{}
|
|
|
|
// Ordered scenario: each step needs state from earlier ones. A failed
|
|
// subtest must stop the rest — otherwise sc.panel stays nil and the next
|
|
// step panics, masking the real failure (as seen on the v1.0.0 release CI).
|
|
run := func(name string, fn func(*testing.T)) {
|
|
if t.Failed() {
|
|
return
|
|
}
|
|
t.Run(name, fn)
|
|
}
|
|
|
|
run("startup_processes_running", func(t *testing.T) {
|
|
if err := checkSupervisorProcesses(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := checkDKIMTree(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := checkPostfixHardening(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := checkInboundRelayOff(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := checkCitedPackageVersions(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := checkPort25ConfigCombinations(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := checkLogrotateConfigMode(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := checkLogrotateRotation(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := waitForPanelReady(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
// The image is pushed as the release, so it must say it is the release:
|
|
// the footer, -version and the backup/restore version check all read this
|
|
// stamp. Release images were once stamped "e2e" because this stand rebuilt
|
|
// them with its own VERSION (audit 2026-10-12, H1).
|
|
run("image_stamped_with_version", func(t *testing.T) {
|
|
want := expectedVersion()
|
|
for _, bin := range []string{"panel", "selfpost-backup"} {
|
|
out, err := h.execIn("selfpost", bin, "-version")
|
|
if err != nil {
|
|
t.Fatalf("%s -version: %v", bin, err)
|
|
}
|
|
if got := strings.TrimSpace(out); got != want {
|
|
t.Fatalf("%s -version = %q, want %q", bin, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
run("renewed_certificate_picked_up", func(t *testing.T) {
|
|
if err := checkCertRenewalPickedUp(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
run("setup_and_login", func(t *testing.T) {
|
|
token, err := readSetupToken(h)
|
|
if err != nil {
|
|
t.Fatalf("read setup token: %v", err)
|
|
}
|
|
p, err := newPanelClient()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := p.setup(token, adminUser, adminPassword); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
if err := p.login(adminUser, adminPassword); err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
sc.panel = p
|
|
})
|
|
|
|
run("health_shows_what_is_served", func(t *testing.T) {
|
|
testHealthShowsWhatIsServed(t, sc)
|
|
})
|
|
run("form_post_with_the_browsers_origin", func(t *testing.T) {
|
|
testFormPostWithTheBrowsersOrigin(t, sc)
|
|
})
|
|
|
|
run("inbound_ui_absent_when_disabled", func(t *testing.T) {
|
|
resp, body, err := sc.panel.get("/overview")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
if strings.Contains(body, `href="/inbound/domains"`) {
|
|
t.Fatal("status page shows Inbound nav while INBOUND_RELAY_ENABLE is off")
|
|
}
|
|
resp, _, err = sc.panel.get("/inbound/domains")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != 404 {
|
|
t.Fatalf("GET /inbound/domains = %d, want 404 with inbound relay off", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
// The panel runs postqueue as its own user. That works only while the queue's
|
|
// public/ directory belongs to Postfix's setgid group, which the entrypoint
|
|
// has to restore after its recursive chown of the queue tree.
|
|
run("queue_readable_by_the_panel", func(t *testing.T) {
|
|
resp, body, err := sc.panel.get("/outbound/queue")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("GET /outbound/queue = %d", resp.StatusCode)
|
|
}
|
|
if strings.Contains(body, "Could not read the mail queue") {
|
|
t.Fatal("the panel could not run postqueue -p (queue directory permissions)")
|
|
}
|
|
if !strings.Contains(body, "Mail queue is empty") {
|
|
t.Fatalf("the queue page does not show postqueue's listing of an empty queue:\n%s", body)
|
|
}
|
|
})
|
|
|
|
run("add_domain_and_publish_dkim", func(t *testing.T) {
|
|
id, err := sc.panel.addDomain(senderDomain)
|
|
if err != nil {
|
|
t.Fatalf("add domain: %v", err)
|
|
}
|
|
sc.domainID = id
|
|
|
|
name, value, err := sc.panel.dkimRecord(id)
|
|
if err != nil {
|
|
t.Fatalf("read dkim record: %v", err)
|
|
}
|
|
sc.dkimName, sc.dkimValue = name, value
|
|
|
|
relName, ok := trimZoneSuffix(name)
|
|
if !ok {
|
|
t.Fatalf("dkim record name %q is not under e2e.test", name)
|
|
}
|
|
sc.dkimZoneRelName = relName
|
|
|
|
records, err := publishTXT(h.stageDir, sc.zoneRecords, txtRecord{name: relName, value: value})
|
|
if err != nil {
|
|
t.Fatalf("publish dkim record to fake zone: %v", err)
|
|
}
|
|
sc.zoneRecords = records
|
|
})
|
|
|
|
run("add_application", func(t *testing.T) {
|
|
login, password, err := sc.panel.addApplication(sc.domainID, "app1", "wildcard", "")
|
|
if err != nil {
|
|
t.Fatalf("add application: %v", err)
|
|
}
|
|
if err := waitForSenderMap(login, password, "alerts@"+senderDomain); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sc.appLogin, sc.appPassword = login, password
|
|
})
|
|
|
|
run("send_verify_dkim_and_status", func(t *testing.T) {
|
|
token := uniqueToken("positive")
|
|
res := attemptSend(sendAttempt{
|
|
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
|
from: "alerts@" + senderDomain, to: recipient,
|
|
subject: "e2e " + token, body: "hello from selfpost e2e",
|
|
})
|
|
if !res.ok() {
|
|
t.Fatalf("positive-path send failed: dial=%v auth=%v mail=%v rcpt=%v data=%v",
|
|
res.dialErr, res.authErr, res.mailErr, res.rcptErr, res.dataErr)
|
|
}
|
|
|
|
file, raw, err := findSinkFile(h.stageDir, token, 30*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("message never reached the sink: %v", err)
|
|
}
|
|
if err := verifyDKIM(raw, senderDomain); err != nil {
|
|
t.Fatalf("DKIM did not verify against the record the panel published: %v", err)
|
|
}
|
|
if err := checkPanelSignature(h, file, raw, senderDomain); err != nil {
|
|
t.Fatalf("the panel's signer: %v\n%s", err, raw)
|
|
}
|
|
if err := checkReceivedHidden(raw); err != nil {
|
|
t.Fatalf("port 465 should hide the application's session: %v", err)
|
|
}
|
|
|
|
if err := waitFor("send-log row to reach status=sent", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
|
rows, err := sc.panel.sendLogRows(senderDomain, "")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if containsCell(rows, "sent") {
|
|
return true, nil
|
|
}
|
|
return false, fmt.Errorf("send log not yet at status=sent (still %s)", firstStatusCell(rows))
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The only row so far; its page is looked at again once the log has
|
|
// been rotated (delivery_log_survives_rotation).
|
|
page, err := firstDeliveryPage(sc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sc.deliveryPage = page
|
|
})
|
|
|
|
run("notifications_set_up", func(t *testing.T) {
|
|
testNotificationsSetUp(t, sc)
|
|
})
|
|
run("negative_level2_ratelimit_via_panel", func(t *testing.T) {
|
|
testLevel2RateLimit(t, sc)
|
|
})
|
|
run("rate_limit_refusal_is_notified", func(t *testing.T) {
|
|
testRateLimitRefusalIsNotified(t, sc)
|
|
})
|
|
run("negative_sender_login_mismatch", func(t *testing.T) {
|
|
testSenderLoginMismatch(t, sc)
|
|
})
|
|
run("negative_no_auth_rejected", func(t *testing.T) {
|
|
testNoAuthRejected(t, sc)
|
|
})
|
|
run("negative_foreign_relay_rejected", func(t *testing.T) {
|
|
testForeignRelayRejected(t, sc)
|
|
})
|
|
run("sender_check_holds_the_from_line", func(t *testing.T) {
|
|
testSenderCheck(t, sc)
|
|
})
|
|
run("passed_from_lines_signed_for_their_domain_only", func(t *testing.T) {
|
|
testSignedForItsDomainOnly(t, sc)
|
|
})
|
|
run("refusals_show_postfixs_line", func(t *testing.T) {
|
|
testRefusalsShowPostfixsLine(t, sc)
|
|
})
|
|
run("refusal_recovered_from_the_log", func(t *testing.T) {
|
|
testRefusalRecoveredFromTheLog(t, sc)
|
|
})
|
|
run("login_in_other_capitals_does_not_authenticate", func(t *testing.T) {
|
|
testLoginCaseMatters(t, sc)
|
|
})
|
|
run("panel_down_defers_mail", func(t *testing.T) {
|
|
testPanelDownDefersMail(t, sc)
|
|
})
|
|
run("panel_hung_mid_message_is_counted", func(t *testing.T) {
|
|
testPanelHungMidMessage(t, sc)
|
|
})
|
|
run("session_survives_restart", func(t *testing.T) {
|
|
testSessionSurvivesRestart(t, sc)
|
|
})
|
|
run("sasldb_changed_by_rename", func(t *testing.T) {
|
|
testSASLDBChangedByRename(t, sc)
|
|
})
|
|
run("supervision_stops_postfix_and_gives_up", func(t *testing.T) {
|
|
testSupervision(t, sc)
|
|
})
|
|
run("negative_level1_ratelimit", func(t *testing.T) {
|
|
testLevel1RateLimit(t, sc)
|
|
})
|
|
// Rotates mail.log, so it comes after the steps that read what they need
|
|
// from the current file.
|
|
run("delivery_log_survives_rotation", func(t *testing.T) {
|
|
testDeliveryLogSurvivesRotation(t, sc)
|
|
})
|
|
|
|
// From here on the installation is no longer the one the run started: it
|
|
// is restored from its own backup, then switched to accept mail on port 25.
|
|
run("recipients_as_postfix_logs_them", func(t *testing.T) {
|
|
testRecipientsAsPostfixLogsThem(t, sc)
|
|
})
|
|
run("address_literal_is_not_local", func(t *testing.T) {
|
|
testAddressLiteralIsNotLocal(t, sc)
|
|
})
|
|
run("expired_message_is_closed", func(t *testing.T) {
|
|
testExpiredMessageIsClosed(t, sc)
|
|
})
|
|
run("full_backup_restore", func(t *testing.T) {
|
|
testFullBackupRestore(t, sc)
|
|
})
|
|
run("certbot_layout_followed", func(t *testing.T) {
|
|
testCertbotLayout(t, sc)
|
|
})
|
|
run("enable_port25_features", func(t *testing.T) {
|
|
testEnablePort25Features(t, sc)
|
|
})
|
|
run("dmarc_report_reaches_the_panel", func(t *testing.T) {
|
|
testDMARCReportReachesThePanel(t, sc)
|
|
})
|
|
run("dmarc_junk_is_dropped", func(t *testing.T) {
|
|
testDMARCJunkIsDropped(t, sc)
|
|
})
|
|
run("inbound_relay_delivers_and_refuses", func(t *testing.T) {
|
|
testInboundRelay(t, sc)
|
|
})
|
|
run("inbound_bare_newline_is_one_message", func(t *testing.T) {
|
|
testInboundBareNewline(t, sc)
|
|
})
|
|
}
|
|
|
|
// trimZoneSuffix strips the "e2e.test" suffix a fully-qualified DKIM record
|
|
// name carries, returning the name relative to the zone db.zone's $ORIGIN
|
|
// expects (see dns_zone.go).
|
|
func trimZoneSuffix(fqdn string) (string, bool) {
|
|
const suffix = ".e2e.test"
|
|
if len(fqdn) > len(suffix) && fqdn[len(fqdn)-len(suffix):] == suffix {
|
|
return fqdn[:len(fqdn)-len(suffix)], true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
var tokenCounter int
|
|
|
|
// uniqueToken returns a short, human-readable, monotonically distinct marker
|
|
// safe to embed in a Subject header and grep for in the sink's dump
|
|
// directory.
|
|
func uniqueToken(label string) string {
|
|
tokenCounter++
|
|
return fmt.Sprintf("%s-%d-%d", label, time.Now().UnixNano(), tokenCounter)
|
|
}
|
|
|
|
func containsCell(html, needle string) bool {
|
|
return strings.Contains(html, `<span class="tag`) && strings.Contains(html, `">`+needle+`</span>`)
|
|
}
|
|
|
|
func firstStatusCell(html string) string {
|
|
m := statusCellPattern.FindStringSubmatch(html)
|
|
if m == nil {
|
|
return "(no rows yet)"
|
|
}
|
|
return m[1]
|
|
}
|