Files
selfpost/test/e2e/mail_helpers.go
T
mixandClaude Opus 5.5 faa3c11ae7
test / test (push) Waiting to run
panel-dkim-signing stage 2: OpenDKIM removed
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>
2026-10-16 01:05:39 +03:00

199 lines
7.3 KiB
Go

package e2e
import (
"bytes"
"fmt"
"net/mail"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/emersion/go-msgauth/dkim"
)
// senderDomain and recipient are fixed for the whole run: one sending domain
// registered in the panel, and the sink-MX as the single recipient every
// positive send targets. Here, not in main_test.go: smtp_client.go uses them,
// and the package builds without its tests (staticcheck reads it so).
const (
senderDomain = "sender.e2e.test"
recipient = "rcpt@sink.e2e.test"
)
// findSinkMessage polls the sink-MX's dump directory (see test/e2e/sink) for
// a file whose contents contain token — the unique Subject-line marker every
// test send carries — and returns it. Polling a directory listing rather than
// sleeping a fixed duration is what keeps this deterministic even under a
// slow CI runner (plan C.4).
func findSinkMessage(stageDir, token string, timeout time.Duration) ([]byte, error) {
_, raw, err := findSinkFile(stageDir, token, timeout)
return raw, err
}
// findSinkFile is findSinkMessage that also says the file's name in the
// sink's /mail (the same directory, mounted).
func findSinkFile(stageDir, token string, timeout time.Duration) (string, []byte, error) {
dir := filepath.Join(stageDir, "mail-stage")
var name string
var found []byte
err := waitFor(fmt.Sprintf("sink-MX to receive a message tagged %q", token), timeout, 300*time.Millisecond, func() (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
for _, e := range entries {
if e.IsDir() {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
if bytes.Contains(b, []byte(token)) {
name, found = e.Name(), b
return true, nil
}
}
return false, fmt.Errorf("no dumped message contains %q yet (%d on disk)", token, len(entries))
})
return name, found, err
}
// verifyDKIM checks that raw (a message dumped by the sink) carries a DKIM
// signature for domain and that every signature for it validates against the
// public key published in the fake zone — i.e. exactly what a real receiver
// would check, using DNS the harness itself controls instead of
// net.DefaultResolver (plan C.4).
func verifyDKIM(raw []byte, domain string) error {
verifications, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{
LookupTXT: lookupTXTFunc(),
})
if err != nil {
return fmt.Errorf("dkim verify: %w", err)
}
n := 0
for _, v := range verifications {
if v.Domain == domain {
if v.Err != nil {
return fmt.Errorf("dkim signature for %s did not validate: %w", domain, v.Err)
}
n++
}
}
if n == 0 {
return fmt.Errorf("no DKIM signature found for domain %s (got %d signature(s))", domain, len(verifications))
}
return nil
}
// checkPanelSignature is the oracle of docs/plans/panel-dkim-signing.md: the
// message carries one DKIM signature, the panel's, for domain - signed as
// OpenDKIM signed until 2.8.0 (relaxed/simple, rsa-sha256, From over-signed)
// - and dkimpy (python3-dkim in the sink image), an implementation that is
// not go-msgauth, the panel's library, verifies it. Stage 1 compared it with
// OpenDKIM's on every message before OpenDKIM left. name is the file in the
// sink's /mail.
func checkPanelSignature(s *stack, name string, raw []byte, domain string) error {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("parse the delivered message: %w", err)
}
sigs := msg.Header["Dkim-Signature"]
if len(sigs) != 1 {
return fmt.Errorf("%d DKIM signatures, want 1, the panel's", len(sigs))
}
tags := dkimTags(sigs[0])
for tag, want := range map[string]string{"d": domain, "c": "relaxed/simple", "a": "rsa-sha256"} {
if tags[tag] != want {
return fmt.Errorf("the signature has %s=%q, want %q", tag, tags[tag], want)
}
}
if h := strings.ToLower(tags["h"]); !strings.HasPrefix(h, "from:") || !strings.HasSuffix(h, ":from") {
return fmt.Errorf("the signature's h=%q does not sign From and over-sign it", tags["h"])
}
out, err := s.execIn("sink", "sh", "-c", fmt.Sprintf("dkimverify --index 0 < '/mail/%s'", name))
if err != nil || !strings.Contains(out, "signature ok") {
return fmt.Errorf("dkimpy: %v\n%s", err, out)
}
return nil
}
// dkimTags splits a DKIM-Signature value into its tags, white space removed
// (RFC 6376 § 3.2: tag=value pairs separated by ";", FWS anywhere).
func dkimTags(v string) map[string]string {
tags := map[string]string{}
for _, part := range strings.Split(v, ";") {
k, val, ok := strings.Cut(part, "=")
if !ok {
continue
}
tags[strings.TrimSpace(k)] = strings.Join(strings.Fields(val), "")
}
return tags
}
// postfixReceived returns the Received: headers of raw that a Postfix smtpd
// wrote - "(Postfix)" after the host name, the only form it writes (Postfix
// 3.10.13, src/smtpd/smtpd.c:3627-3645, "by %s (%s)" with var_mail_name).
// In the stand only SelfPost's Postfix writes one: the tests compose their
// messages without any, and the sink, smtp-sink, adds its own with "by
// <host> (smtp-sink)" (src/smtpstone/smtp-sink.c:520-531,
// mail_file_finish_header()), which the "(Postfix)" filter leaves out.
func postfixReceived(raw []byte) ([]string, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("parse the delivered message: %w", err)
}
var out []string
for _, v := range msg.Header["Received"] {
if strings.Contains(v, "(Postfix)") {
out = append(out, v)
}
}
return out, nil
}
// checkReceivedHidden: the submission ports set smtpd_hide_client_session
// (build/postfix-config.sh), so SelfPost's Received: header is the redacted
// one - it opens with "by", names no client and no protocol (Postfix 3.10.13,
// src/smtpd/smtpd.c:3495, the "from" part written only when the parameter
// is off, and with_protocol emptied with it).
func checkReceivedHidden(raw []byte) error {
recv, err := postfixReceived(raw)
if err != nil {
return err
}
if len(recv) != 1 {
return fmt.Errorf("want one Received: header from SelfPost's Postfix, got %d: %q", len(recv), recv)
}
if !strings.HasPrefix(recv[0], "by ") || strings.Contains(recv[0], " with ") {
return fmt.Errorf("the Received: header names the client session: %q", recv[0])
}
return nil
}
// connectFromPattern matches Postfix's smtpd "connect from ...[ADDR]" log
// line, which is how the harness learns the source address Postfix itself
// observed for a just-made connection (needed to configure a level-2 rate
// limit's allowed-IPs list — the address a host-published port is seen as
// inside the container depends on Docker's NAT and isn't worth hard-coding).
// Postfix 3.10.13, src/smtpd/smtpd.c, smtpd_service(): msg_info("connect from
// %s", namaddr), namaddr being "name[addr]".
var connectFromPattern = regexp.MustCompile(`connect from [^\[]*\[([0-9a-fA-F.:]+)\]`)
// lastSMTPClientIP reads mail.log inside the selfpost container and returns
// the most recent address Postfix's smtpd logged a connection from.
func lastSMTPClientIP(s *stack) (string, error) {
out, err := s.execIn("selfpost", "tail", "-n", "200", "/data/log/mail.log")
if err != nil {
return "", err
}
matches := connectFromPattern.FindAllStringSubmatch(out, -1)
if len(matches) == 0 {
return "", fmt.Errorf("no \"connect from\" line in mail.log yet")
}
return matches[len(matches)-1][1], nil
}