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>
12 KiB
Plan: panel-dkim-signing
Status: agreed, 2026-10-15 - the row is agreed (owner, 2026-10-15, the
order port25-bare-newline -> base-trixie -> panel-dkim-signing); three
questions below were settled one at a time the same day.
Date: 2026-10-15
Version: 2.8.0 MINOR (after base-trixie, shipped 2.7.0). No migration;
nothing for an operator to do.
Goal
The panel signs outgoing mail itself, in its own milter, and OpenDKIM
leaves the image. The analysis (own-mta-builds
§ 3 E) and the roadmap section give the reasons: OpenDKIM is a 2018 beta
with three open 2026 CVEs and nobody upstream; every OpenDKIM limit
SelfPost works around goes with it (the syslog relay, the From: parser
port and its 256-byte refusal, the tables reloaded by a cross-uid signal
and CAP_KILL, keys shared through a group).
What stays the same for an operator: the keys, the selectors, the DNS records, the panel's pages (one label on Health excepted, Q3).
What the signer does
- Library:
github.com/emersion/go-msgauth/dkimv0.7.0 (MIT; the author of the go-milter v0.4.1 we run; the e2e suite verifies with its v0.6.8 today). Read for this plan,dkim/sign.go:NewSignerhashes the body as it is written (anio.Pipeinto a goroutine), so a message is not buffered;HeaderKeysis written toh=in the order given, each name picked last instance first (header.go,newHeaderPicker), and a name listed once more than it occurs is kept inh=with nothing hashed for it - the over-signing RFC 6376 § 5.4.2 describes; RSA-SHA256; the signature is the caller's to insert. - What is signed:
relaxed/simple, asbuild/opendkim.confsigns today.h=: every header of the message whose name is in OpenDKIM'sdkim_should_signhdrs[](libopendkim/dkim.c:223-246: from, reply-to, subject, date, to, cc, the five resent-, in-reply-to, references, the seven list-), each occurrence, in message order, plusFromonce more (OversignHeaders Fromtoday). Absent names are not listed, as OpenDKIM does not list them. - Which key: on 465/587 the key of the domain the SASL login belongs
to -
{auth_authen}, thenBindings.DomainOf(login), the table the sender check already reads (internal/milter/sender.go:262-269). For the panel's own notifications (sendmail(1),non_smtpd_milters) the key of the server's name, asEnsureServerSignedprovides it today. The sender check has already heldFrom:to that same domain, so the signature'sd=is theFrom:domain DMARC aligns on. - What Postfix hands a milter, read in Postfix 3.10.13,
src/milter/milter8.c:2329-2400,milter8_header(): Postfix's ownReceived:is not shown (and an inserted header at index 0 lands above it, "relative to the message as delivered"); a multi-line header comes as text separated by\n; one space after the colon is eaten unless the milter asks forSMFIP_HDR_LEADSPC(OptHeaderLeadingSpace, which the sender check already asks for); body records end in\r\n(milter8_body(), :2470-2500). The signer rebuilds each header asName:+ value with\nmade\r\n- go-msgauth's owncmd/dkim-milterwritesname + ": " + value + "\r\n"and would sign a folded header wrongly. A unit test signs folded, tab-led and space-free headers and an independent verifier checks them. - When it cannot sign (no key file, a login with no domain, a read
error): temporary failure,
default_action=tempfail, as OpenDKIM is attached today. A message is never sent unsigned by accident. - 8-bit mail:
disable_mime_output_conversion=yes. Postfix converts an 8-bit message to 7-bit for a server that does not announce 8BITMIME (defaultno,src/global/mail_params.h:3088), which breaks any body hash - OpenDKIM's today as well. Not set until now; set with this row.
Decisions settled before this plan
From the roadmap section and the analysis, agreed with the row: the
library; relaxed/simple; the header set and From over-signed; the key
by the login's domain and by the server's name for local mail; strict;
the removals below; keys, selectors and records unchanged; the e2e oracle
an independent verifier; a message to a real mailbox before the cut.
Decided here (technical, the owner may overrule):
- The oracle is dkimpy (
python3-dkim1.1.8-2 in trixie,/usr/bin/dkimverify, seen indebian:trixie2026-10-15) - a separate implementation in another language, maintained, packaged by Debian; not OpenDKIM'sopendkim-testmsg(the code this row removes) and not go-msgauth (the signer's own library). It runs in the e2e sink's network against the stand's CoreDNS. - A cross-check before OpenDKIM goes: on the stand of stage 1 both
sign the same messages for a while (OpenDKIM after the panel in the
chain) and the two signatures'
bh=andh=are compared on every e2e message; they must agree before stage 2 removes OpenDKIM. - The From: parser port goes. It exists so that the sender check
judges the address OpenDKIM chooses the key by. With the key chosen by
the login, the check keeps
net/mail(one RFC 5322 address at an allowed domain) and the 256-byte refusal goes; the refusal texts that name OpenDKIM are rewritten.
Open questions (to the owner, one at a time)
- Q1. Where the signer runs. (a) Its own role in the panel process on
two sockets of its own:
sign.sockfor 465/587 (the key by the login) andsign-local.sockfor local submission (the server's key only), in the chain where OpenDKIM is now: sender check, signer, journal. (b) Inside the sender check's milter: one socket fewer, but the check would receive every body, and local mail - which has no login - would need the same milter to tell "no login" from "local", deciding a key by the absence of something. Recommended: (a) - the socket a message comes through, not a missing login, decides that the server's key may sign it; the sender check stays a header-only milter; and moving the signer into a process of its own later is a change of where it is started. Decided (owner, 2026-10-15): (a). - Q2. The key directory's name.
/data/opendkim/keys/<domain>/<sel>.privatestays as it is, or becomes/data/dkim/...(the entrypoint moves the tree once; KeyTable and SigningTable are deleted). A backup restores only into the image of its own version, so no older archive meets the new name. Recommended: rename - the cost is the entrypoint's move, the paths in the code and the backup tests; a directory named after a program the image no longer has would mislead whoever reads/data. Decided (owner, 2026-10-15): rename to/data/dkim. - Q3. Health. The Overview's socket check and the process list name
OpenDKIM and the syslog relay. Recommended: the socket row becomes
"DKIM signer" (the panel's
sign.sock, checked the same way), the two programs leave the process list. A mockup first, as for any page change. Settled 2026-10-15: shown to the owner as a picture; it follows from the card's own rule (a row per milter socket Postfix needs, and the signer is strict), so it is not his choice - the two rows as drawn.
Stages
Stage 1 - the signer, beside OpenDKIM (no release) - done 14de9a0
internal/milter/sign.go: the session (Connect, MailFrom with{auth_authen}, Header, Headers, BodyChunk, Body),OptHeaderLeadingSpace,InsertHeader(0, "DKIM-Signature", ...)(the server'sActionsgain add-header), the key read per message from the key file (a small cache keyed by path and mtime), the selector from the store asRecordreads it.- Unit tests: sign and verify with go-msgauth; folded and tab-led headers; a header absent and present twice; an empty body; a body without a final newline; a login with no domain and a missing key file answer tempfail; the server's socket never signs with a domain's key.
cmd/panel: the role, its two sockets (listenMilter, group selfpost, 0660), the wrapper waits for them.build/postfix-config.sh:smtpd_milters= sender, sign, OpenDKIM, journal (OpenDKIM kept for the cross-check);non_smtpd_milters= sign-local, OpenDKIM;disable_mime_output_conversion=yes.- e2e: dkimpy in the stand; every positive send and the notification
verified by dkimpy against the panel's signature; the panel's and
OpenDKIM's
bh=/h=compared.
Done (2026-10-15, 14de9a0): e2e exit 0 on the server; on an
application's message and on a notification the panel's and OpenDKIM's
signatures agree on h=, bh=, c=, s=, a= and dkimpy verifies both.
Two findings while writing it: a folded header reaches a milter
-separated (the signer rebuilds CRLF), and go-milter calls no Abort on a
dropped connection (a one-hour watchdog closes the signer).
Stage 2 - OpenDKIM removed (no release) - done
- Chain: OpenDKIM out of both milter lists.
- Go:
internal/domain/opendkim.goreduced to key storage (tables,Rebuild,assertConfigSafe, the USR1 reload go),internal/supervisor(its only caller is the reload),internal/syslogrelay,internal/milter/mailparse.goand its tests and testdata, the sender-check texts; Resync's "OpenDKIM's tables" step; Health's liveness set and socket row (Q3);OPENDKIM_SOCKET/OPENDKIM_DIR. - Image: the
opendkimpackage,opendkim.conf, the two supervisord programs, the/dev/loglink and/run/opendkim,usermod -aG selfpost opendkim, crashexit's spared list;deploy/docker-compose.yml'sKILLcapability (its only use was the reload) - kept, see below. - Keys:
/data/opendkim/keys->/data/dkim/keys(Q2): the entrypoint copies the tree once and leaves the old one for a rollback to 2.7.x until the next release; KeyTable and SigningTable go with the new layout. - Tests:
test/upstream/*.sh, e2e's OpenDKIM log and process checks, the 256-byte test rewritten for the new rule.
Done (2026-10-16): internal/domain/keys.go (Keys, NewKeys,
DKIM_DIR) keeps the keys and writes a selector table,
/data/dkim/selectors, a line <name> <selector> each, which the signer
reads instead of the KeyTable: the signer reads no database, as the sender
check reads none - the panel's one SQLite connection is shared with the
pages, and a long query would hold up mail. The table is written before the
signer serves (EnsureServerSigned, moved there from the notifier), so the
first start of 2.8.0 needs nothing of the operator. Keys and table are
0600, directories 0700, owned by the panel; the entrypoint copies the old
keys as the panel user (setpriv), into /data/dkim.new, renamed when
whole, and stops the start if the copy fails - else the server's name would
get a new key. Three findings: KILL stays - measured on the test server,
root without it cannot signal a process of another uid, and supervisord
stops the panel and Postfix's master its children that way (the plan's
"only use" was wrong); the sender check's Allowed took the domain through
strings.ToLower, which makes the Kelvin sign a "k" - the comparison with
OpenDKIM's reading had hidden it, it now folds ASCII only; the Milter
sockets card told the spam filter by its row having a note, which the
signer's rows have too. The e2e drops the OpenDKIM-only cases
(testNeverSignedForAnother) and checks the one signature, the tree's
modes and the cited packages without opendkim, libopendkim11 and
libmilter1.0.1.
Stage 3 - documents, the canary, the cut
- architecture § OpenDKIM -> § DKIM signing; security.md (the mail-path bullets, the accepted risks that were OpenDKIM's); guide; development's cited-package table (opendkim and libopendkim11 leave); the assumptions register's header notes what left; help (design commit first) and Health's label (Q3, mockup first).
- Reviews: technical and security.
- On the test server: a day of the owner's mail; one message from an
application through 465 and one notification to a real mailbox, the
headers read (dkim=pass with the panel's
d=). - Cut
2.8.0.
Risks
- A signing defect fails DMARC for every tenant at once. The answer is the oracle (another implementation), the cross-check against OpenDKIM on the same messages before it goes, and the canary before the cut.
- go-msgauth is small and released slowly. It can be vendored if it ever matters; the signing path is about 300 lines of it.
- Rollback: until stage 2 ships, the chain change is one line; after the cut, the previous image (2.7.x) still has OpenDKIM and reads the same keys - under Q2's rename, from the old path, which the move leaves as a copy until the next release.