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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user