panel-dkim-signing stage 2: OpenDKIM removed
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>
This commit is contained in:
mix
2026-10-16 01:05:39 +03:00
co-authored by Claude Opus 5.5
parent 005922bc7d
commit faa3c11ae7
96 changed files with 971 additions and 2247 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ services:
# Sink-MX: accepts any inbound mail and dumps each transaction to its own
# file under ./mail-stage, which the harness reads directly to check the
# DKIM-Signature header Postfix/OpenDKIM produced.
# DKIM-Signature header the panel's signer produced.
sink:
build:
context: ../sink
+6 -50
View File
@@ -41,13 +41,12 @@ func fromLines(repoRoot, file, domain, other string) ([]string, error) {
return out, nil
}
// testSignedForItsDomainOnly: the sender check and OpenDKIM read From: with
// two parsers, and that they agree was argued, never run (audit 2026-10-12,
// stage 2). Every From: line the check passes for an application is sent as
// that application, and what arrives must be signed for its domain - with a
// signature that validates - and for no other: a line the check reads as the
// application's own and OpenDKIM as another sender's would be signed with
// that sender's key.
// testSignedForItsDomainOnly: every From: line the check passes for an
// application is sent as that application, and what arrives must be signed
// for its domain - with a signature that validates over these headers - and
// for no other (audit 2026-10-12, stage 2). Until 2.8.0 OpenDKIM chose the
// key by the line and this was the check that the two read it alike; the
// panel's signer takes the login's key, and the run holds it to that.
func testSignedForItsDomainOnly(t *testing.T, sc *scenario) {
lines, err := fromLines(h.repoRoot, "from-lines-passed.txt", senderDomain, otherDomain)
if err != nil {
@@ -89,49 +88,6 @@ func testSignedForItsDomainOnly(t *testing.T, sc *scenario) {
}
}
// testNeverSignedForAnother: no line that names another sender of the server
// arrives signed with that sender's key (review of audit stage 2). Since stage
// 2c the check reads a line as OpenDKIM does: one whose address is the
// application's passes, wherever the other sender's name is written, and must
// arrive signed for the application's domain; the rest are refused.
func testNeverSignedForAnother(t *testing.T, sc *scenario) {
lines, err := fromLines(h.repoRoot, "from-lines-other.txt", senderDomain, otherDomain)
if err != nil {
t.Fatal(err)
}
for _, line := range lines {
token := uniqueToken("dkimother")
res := attemptSend(sendAttempt{
authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "alerts@" + senderDomain, headerFrom: line, to: recipient,
subject: "e2e " + token, body: "which key signs this",
})
if err := res.refusedAt("DATA", 550, "5.7.1 From:"); err == nil {
continue
}
if !res.ok() {
t.Errorf("From: %q: neither passed nor refused by the check: %v", line, res.firstErr())
continue
}
raw, err := findSinkMessage(h.stageDir, token, 15*time.Second)
if err != nil {
t.Errorf("From: %q passed and never arrived: %v", line, err)
continue
}
verifications, _ := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{LookupTXT: lookupTXTFunc()})
own := false
for _, v := range verifications {
if v.Domain == otherDomain {
t.Errorf("From: %q arrived signed for %s", line, otherDomain)
}
own = own || (v.Domain == senderDomain && v.Err == nil)
}
if !own {
t.Errorf("From: %q passed and did not arrive signed for %s: %+v", line, senderDomain, verifications)
}
}
}
// testLoginCaseMatters: SASL compares the login byte for byte, so an
// application's login in other capitals does not authenticate. Every lookup on
// the mail path is by the exact login and relies on that (review of audit
+5 -4
View File
@@ -44,11 +44,12 @@ func testHealthShowsWhatIsServed(t *testing.T, sc *scenario) {
return rest
}
// The panel's user reaches all three sockets through the selfpost group,
// as Postfix does: three ok rows and no detail under any.
// The panel's user reaches all four sockets - the signer's two, the
// sender check's, the journal's - through the selfpost group, as Postfix
// does: four ok rows and no detail under any.
sockets := box(health(), "sockets")
if n := strings.Count(sockets, `<tr><td>`); n != 3 {
t.Fatalf("Milter sockets has %d rows, want 3:\n%s", n, sockets)
if n := strings.Count(sockets, `<tr><td>`); n != 4 {
t.Fatalf("Milter sockets has %d rows, want 4:\n%s", n, sockets)
}
if strings.Contains(sockets, `sp-muted sp-small">`) {
t.Fatalf("a milter socket is not answering the panel's dial:\n%s", sockets)
+1 -1
View File
@@ -97,7 +97,7 @@ func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, s
_ = exec.Command("docker", "rm", "-f", name).Run()
defer func() {
// Make bind-mounted /data deletable by Go's TempDir cleanup: the panel
// leaves setup-token/db/opendkim owned by container UIDs.
// leaves setup-token/db/dkim owned by container UIDs.
_ = exec.Command("docker", "exec", name, "sh", "-c",
"chown -R root:root /data && chmod -R a+rwX /data").Run()
_ = exec.Command("docker", "rm", "-f", name).Run()
+21 -29
View File
@@ -88,42 +88,34 @@ func verifyDKIM(raw []byte, domain string) error {
return nil
}
// checkBothSigners is the oracle of docs/plans/panel-dkim-signing.md, stage
// 1: the panel's signer and OpenDKIM both sign (OpenDKIM after the panel in
// the chain, so its signature is the top one - build/postfix-config.sh);
// the two must agree on what they sign - the same h= and bh= - and each
// must verify with dkimpy (python3-dkim in the sink image), an
// implementation that is neither go-msgauth, the panel's library, nor
// OpenDKIM. name is the file in the sink's /mail.
func checkBothSigners(s *stack, name string, raw []byte, domain string) error {
// 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)
}
var sigs []map[string]string
for _, v := range msg.Header["Dkim-Signature"] {
tags := dkimTags(v)
if tags["d"] == domain {
sigs = append(sigs, tags)
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 len(sigs) != 2 {
return fmt.Errorf("%d DKIM signatures for %s, want 2 (the panel's and OpenDKIM's)", len(sigs), domain)
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"])
}
for _, tag := range []string{"bh", "h", "c", "s", "a"} {
a, b := sigs[0][tag], sigs[1][tag]
if tag == "h" {
a, b = strings.ToLower(a), strings.ToLower(b)
}
if a != b {
return fmt.Errorf("the signatures differ in %s=: OpenDKIM %q, the panel %q", tag, a, b)
}
}
for i := range sigs {
out, err := s.execIn("sink", "sh", "-c", fmt.Sprintf("dkimverify --index %d < '/mail/%s'", i, name))
if err != nil || !strings.Contains(out, "signature ok") {
return fmt.Errorf("dkimpy on signature %d (%s): %v\n%s", i, []string{"OpenDKIM's", "the panel's"}[i], err, out)
}
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
}
+2 -5
View File
@@ -105,7 +105,7 @@ func TestE2E(t *testing.T) {
if err := checkSupervisorProcesses(h); err != nil {
t.Fatal(err)
}
if err := checkOpenDKIMLogs(h); err != nil {
if err := checkDKIMTree(h); err != nil {
t.Fatal(err)
}
if err := checkPostfixHardening(h); err != nil {
@@ -274,7 +274,7 @@ func TestE2E(t *testing.T) {
if err := verifyDKIM(raw, senderDomain); err != nil {
t.Fatalf("DKIM did not verify against the record the panel published: %v", err)
}
if err := checkBothSigners(h, file, raw, senderDomain); err != nil {
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 {
@@ -333,9 +333,6 @@ func TestE2E(t *testing.T) {
run("refusal_recovered_from_the_log", func(t *testing.T) {
testRefusalRecoveredFromTheLog(t, sc)
})
run("lines_naming_another_sender_never_signed_for_it", func(t *testing.T) {
testNeverSignedForAnother(t, sc)
})
run("login_in_other_capitals_does_not_authenticate", func(t *testing.T) {
testLoginCaseMatters(t, sc)
})
+2 -2
View File
@@ -87,7 +87,7 @@ func testNotificationsSetUp(t *testing.T, sc *scenario) {
// testRateLimitRefusalIsNotified checks the whole path of a notification: the
// level-2 refusal of the previous step is noticed by the notifier (it looks
// every minute), written to the administrator, submitted by the unprivileged
// panel through sendmail, signed by OpenDKIM with the key of the server's own
// panel through sendmail, signed by the panel's signer with the key of the server's own
// name and delivered. It also holds what a notification is not: a row of the
// Outbound log. That it is said once is for the unit tests to hold
// (internal/notify): here it would be a minute of waiting for nothing to happen.
@@ -108,7 +108,7 @@ func testRateLimitRefusalIsNotified(t *testing.T, sc *scenario) {
t.Errorf("the notification lacks %q:\n%s", want, msg)
}
}
if err := checkBothSigners(h, file, raw, serverName); err != nil {
if err := checkPanelSignature(h, file, raw, serverName); err != nil {
t.Errorf("the panel's signer on its own mail: %v", err)
}
if err := verifyDKIM(raw, serverName); err != nil {
+4 -4
View File
@@ -59,8 +59,8 @@ var port25Cases = []port25Case{
// drives it.
{param: "smtpd_forbid_bare_newline", contains: "normalize", wantExact: true},
// No antispam milter configured: the inbound journal milter
// alone. The submission chain (strict OpenDKIM + journal-milter)
// must not reach inbound mail.
// alone. The submission chain (the strict sender check and
// signer + journal-milter) must not reach inbound mail.
{param: "smtpd_milters", contains: "{unix:/run/selfpost/inbound-journal.sock,default_action=accept}", wantExact: true},
},
},
@@ -104,8 +104,8 @@ var port25Cases = []port25Case{
"INBOUND_ANTISPAM_MILTER_ACTION=accept",
},
check: []optionCheck{
// Exact equality also covers the absence of OpenDKIM/journal-milter
// sockets — anything else there would fail the comparison.
// Exact equality also covers the absence of the submission
// chain's sockets — anything else there would fail the comparison.
{param: "smtpd_milters", contains: "{inet:antispam:11332,default_action=accept},{unix:/run/selfpost/inbound-journal.sock,default_action=accept}", wantExact: true},
},
},
+15 -11
View File
@@ -14,7 +14,7 @@ import (
// excluded: autostart=false, so it rests STOPPED until its first run and
// EXITED after it (supervisor 4.2.5, supervisor/process.py,
// Subprocess.finish(); assumptions C-V10).
var wantRunning = []string{"syslog-relay", "opendkim", "panel", "postfix", "cert-reload", "logrotate"}
var wantRunning = []string{"panel", "postfix", "cert-reload", "logrotate"}
// checkSupervisorProcesses shells into the container directly (not through the
// panel's /overview page) so it works before an administrator account even
@@ -130,16 +130,20 @@ func checkCitedPackageVersions(s *stack) error {
return nil
}
// checkOpenDKIMLogs waits for OpenDKIM's start line in the container's log:
// OpenDKIM logs through syslog(3) only, and /dev/log is the syslog-relay
// program's (audit 2026-10-12, L30). OpenDKIM 2.11.0~beta2,
// opendkim/opendkim.c: syslog(LOG_INFO, "%s v%s starting%s%s%s",
// DKIMF_PRODUCT, ...), DKIMF_PRODUCT "OpenDKIM Filter" (opendkim.h).
func checkOpenDKIMLogs(s *stack) error {
return waitFor("OpenDKIM's start line in the container log", 20*time.Second, 500*time.Millisecond, func() (bool, error) {
out := s.logs("selfpost")
return strings.Contains(out, "opendkim[") && strings.Contains(out, "OpenDKIM Filter v"), fmt.Errorf("no OpenDKIM line in the log")
})
// checkDKIMTree: the keys and the selector table are the panel's alone,
// directories 0700 and files 0600 (build/entrypoint.sh; internal/domain,
// Keys) - no other program reads them since OpenDKIM left - and the table
// is there, written before the signer serves (cmd/panel/signer.go).
func checkDKIMTree(s *stack) error {
out, err := s.execIn("selfpost", "sh", "-c",
`test -f /data/dkim/selectors && find /data/dkim ! \( -user panel -group panel \( -type d -perm 0700 -o -type f -perm 0600 \) \)`)
if err != nil {
return fmt.Errorf("the DKIM tree: %v\n%s", err, out)
}
if strings.TrimSpace(out) != "" {
return fmt.Errorf("not the panel's alone under /data/dkim:\n%s", out)
}
return nil
}
// checkPostfixHardening reads back what postfix-config.sh sets against
+12 -10
View File
@@ -41,6 +41,7 @@ func testSenderCheck(t *testing.T, sc *scenario) {
wildcard := who{sc.appLogin, sc.appPassword, "alerts@" + senderDomain}
list := who{listLogin, listPassword, "billing@" + senderDomain}
refused := 0 // the cases the check refuses: each a row of the Outbound log
for _, c := range []struct {
name string
as who
@@ -66,15 +67,15 @@ func testSenderCheck(t *testing.T, sc *scenario) {
// app-list, so the wildcard application may not have it, in the
// header as in the envelope (assumptions B-PS1; the envelope below).
{"the address listed for another application", wildcard, "billing@" + senderDomain, false},
// Since 2.5.2 the address OpenDKIM reads decides (OpenDKIM's parser,
// internal/milter/mailparse.go): another sender's name in the display
// name passes, and is signed for the application's own domain
// (passed_from_lines_signed_for_their_domain_only looks at the key).
// The address a mail program reads decides: another sender's name
// in a quoted display name passes, and is signed for the
// application's own domain - the key is the login's
// (passed_from_lines_signed_for_their_domain_only looks at it).
{"another sender in the display name", wildcard, `"director@` + otherDomain + `" <alerts@` + senderDomain + `>`, true},
// Past 256 bytes OpenDKIM matches its SigningTable against the
// address cut short, which here would end in the other domain's
// name (opendkim.c, dkimf_apply_signtable(), MAXADDRESS).
{"an address longer than OpenDKIM reads", wildcard, `"` + strings.Repeat("a", 256-len("@"+otherDomain)-1) + `@` + otherDomain + `x"@` + senderDomain, false},
// Until 2.8.0 OpenDKIM read an address past 256 bytes cut short, and
// this one would have ended in the other domain's name; refused
// then, it is the application's own address now.
{"a long quoted local part naming another domain", wildcard, `"` + strings.Repeat("a", 256-len("@"+otherDomain)-1) + `@` + otherDomain + `x"@` + senderDomain, true},
} {
token := uniqueToken("from")
res := attemptSend(sendAttempt{
@@ -92,6 +93,7 @@ func testSenderCheck(t *testing.T, sc *scenario) {
}
continue
}
refused++
if res.ok() {
t.Errorf("%s: From: %s was accepted", c.name, c.header)
continue
@@ -129,8 +131,8 @@ func testSenderCheck(t *testing.T, sc *scenario) {
if err != nil {
return false, err
}
if n := strings.Count(rows, `">wrong From</span>`); n < 6 {
return false, fmt.Errorf("%d rows tagged wrong From, want 6", n)
if n := strings.Count(rows, `">wrong From</span>`); n < refused {
return false, fmt.Errorf("%d rows tagged wrong From, want %d", n, refused)
}
return true, nil
}); err != nil {
+1 -1
View File
@@ -1,7 +1,7 @@
# SMTP sink-MX for the e2e stand (plan C.4): accepts any mail on 25 and dumps
# each transaction to its own file under /mail, so the test harness can read
# the raw received message (headers + body, including the DKIM-Signature
# Postfix/OpenDKIM added) straight off disk. smtp-sink ships in the postfix
# the panel's signer added) straight off disk. smtp-sink ships in the postfix
# package itself. python3-dkim (dkimpy 1.1.8-2 in trixie, /usr/bin/dkimverify)
# is the independent DKIM verifier the suite holds the panel's signer to
# (docs/plans/panel-dkim-signing.md); it looks the keys up through the
+1 -1
View File
@@ -132,7 +132,7 @@ func (s *stack) down() {
// reclaimData chowns/chmods the /data bind mount from inside the still-running
// selfpost container so the host test user can delete it afterwards. Files
// written as panel/postfix (setup-token 0600, opendkim tree, sqlite) otherwise
// written as panel/postfix (setup-token 0600, dkim tree, sqlite) otherwise
// leave EACCES on TempDir/stage cleanup (CI hostname-gate + prepareStage).
func (s *stack) reclaimData() {
_, _ = s.execIn("selfpost", "sh", "-c", "chown -R root:root /data && chmod -R a+rwX /data")
+1 -1
View File
@@ -165,7 +165,7 @@ func testSupervision(t *testing.T, sc *scenario) {
if !strings.Contains(h.logs("selfpost"), "crashexit: logrotate entered FATAL; it is not on the mail path, the container stays up") {
t.Fatalf("crashexit did not spare logrotate:\n%s", h.logs("selfpost"))
}
for _, p := range []string{"opendkim", "panel", "postfix"} {
for _, p := range []string{"panel", "postfix"} {
if state, status := programState(p); state != "RUNNING" {
t.Fatalf("program %s with logrotate FATAL: %s", p, status)
}