Files
selfpost/docs/plans/panel-dkim-signing.md
T
mixandClaude Opus 5.5 ac21180d58
test / test (push) Waiting to run
release: 2.8.0: panel-dkim-signing - the panel signs, OpenDKIM gone
Cut at the owner's word (2026-10-16, "Выпускай").

The panel signs outgoing mail in a milter of its own (go-msgauth,
relaxed/simple, the login's key on 465/587 and the server's name's on
local mail); OpenDKIM, the syslog relay, the USR1 reload and the From:
parser port are gone. Keys move to /data/dkim (copied once; /data/opendkim
kept for a rollback, left out of backups). The sender check holds Sender:
like From:. go-milter is built patched not to keep every header. The queue
is handed to Postfix's users by name before "postfix check". Commits
005922b..ca3a183 (005922b, faa3c11, c2f63d2, f0dd9f9, 2cfd239, d9d9af3,
0cceb54, 80faf1a, f1d90a2, ca3a183).

On the test server since 2026-10-15 22:48 UTC (2.7.0-post.c2f63d2, now
2.7.0-post.f1d90a2, the same code as this cut): healthy, keys unchanged.
The first rollout (faa3c11) crash-looped five minutes on the queue's owner
numbers and was rolled back; fixed in c2f63d2. The owner's canary: Gmail
dkim/spf/dmarc pass with d=carrel.mixfed.ru, Yandex dkim/spf pass, not
spam; his From:/Sender: check, thirteen cases, all as expected.

CHANGELOG [2.8.0]; pins in deploy/docker-compose.yml, README and the guide
(the guide's rollback command keeps naming 2.7.0 on purpose); development's
"from pin"; roadmap row and recommended order; plan stage 3 closed. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:36:54 +03:00

255 lines
15 KiB
Markdown

# Plan: panel-dkim-signing
**Status:** shipped `2.8.0` (2026-10-16); 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](../own-mta-builds-2026-10-15.md)
§ 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/dkim` v0.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`: `NewSigner` hashes the
body as it is written (an `io.Pipe` into a goroutine), so a message is
not buffered; `HeaderKeys` is written to `h=` in the order given, each
name picked last instance first (`header.go`, `newHeaderPicker`), and a
name listed once more than it occurs is kept in `h=` 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`, as `build/opendkim.conf` signs
today. `h=`: every header of the message whose name is in OpenDKIM's
`dkim_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, plus `From` once more
(`OversignHeaders From` today). 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}`, then `Bindings.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, as `EnsureServerSigned` provides it today. The sender
check has already held `From:` to that same domain, so the signature's
`d=` is the `From:` 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 own
`Received:` 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 for `SMFIP_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 as
`Name:` + value with `\n` made `\r\n` - go-msgauth's own
`cmd/dkim-milter` writes `name + ": " + 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
(default `no`, `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-dkim` 1.1.8-2 in trixie,
`/usr/bin/dkimverify`, seen in `debian:trixie` 2026-10-15) - a separate
implementation in another language, maintained, packaged by Debian; not
OpenDKIM's `opendkim-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=` and `h=` 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.sock` for 465/587 (the key by the login)
and `sign-local.sock` for 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>.private`
stays 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`
- [x] `internal/milter/sign.go`: the session (Connect, MailFrom with
`{auth_authen}`, Header, Headers, BodyChunk, Body), `OptHeaderLeadingSpace`,
`InsertHeader(0, "DKIM-Signature", ...)` (the server's `Actions` gain
add-header), the key read per message from the key file (a small cache
keyed by path and mtime), the selector from the store as `Record` reads
it.
- [x] 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.
- [x] `cmd/panel`: the role, its two sockets (`listenMilter`, group
selfpost, 0660), the wrapper waits for them.
- [x] `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`.
- [x] 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
- [x] Chain: OpenDKIM out of both milter lists.
- [x] Go: `internal/domain/opendkim.go` reduced to key storage (tables,
`Rebuild`, `assertConfigSafe`, the USR1 reload go), `internal/supervisor`
(its only caller is the reload), `internal/syslogrelay`,
`internal/milter/mailparse.go` and 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`.
- [x] Image: the `opendkim` package, `opendkim.conf`, the two supervisord
programs, the `/dev/log` link and `/run/opendkim`, `usermod -aG selfpost
opendkim`, crashexit's spared list; `deploy/docker-compose.yml`'s `KILL`
capability (its only use was the reload) - **kept, see below**.
- [x] 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.
- [x] 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
- [x] 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).
- [x] Reviews: technical and security.
*Done (2026-10-16):* help and Health went with stage 2 (design `005922b`);
the reviews ran on stages 1-2 and their findings are fixed in `c2f63d2`,
with the first rollout's incident (the queue's owner numbers); the
documents in the commit after it. `Sender:` is held to the login like
`From:` (security review F1; the owner's decision, 2026-10-16). go-milter
v0.4.1 kept every header in a map of its own per session, on 465/587 and on
port 25 alike: the panel builds it patched not to
(`third_party/go-milter/PATCHES.md`; the owner's decision, 2026-10-16).
- [x] 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=`).
- [x] Cut `2.8.0`.
*Done (2026-10-16):* the owner's message through 465 (`dev/send-check.ps1`)
arrived at Gmail with `dkim=pass header.i=@carrel.mixfed.ru
header.s=selfpost`, `spf=pass`, `dmarc=pass`, and at Yandex with
`dkim=pass` and `X-Yandex-Spam: 1`; its `h=` over-signs the shown headers.
His `dev/from-check.ps1` sent thirteen `From:`/`Sender:` cases: four
accepted and delivered, nine refused as expected, each a send-log row of
reason `from` or `sender`. The notification to a real mailbox was not
sent - nothing raised one; its signature is verified by dkimpy on every e2e
run, and the server name's key, unchanged by the move (digests compared),
matched its published record on 2026-10-15. The day of mail was cut short
at the owner's word ("Выпускай", 2026-10-16), the test server having run
the stage's code since 2026-10-15 22:48 UTC. Cut as `2.8.0`, no tag.
## 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 keys
from the old path, which the move leaves as a copy until the next release
- the keys as they were at the upgrade: a domain added under 2.8.x is not
in that tree, and 2.7.x sends its mail unsigned; one re-added under 2.8.x
has another key there. 2.7.x reads the queue as its own postfix (uid
101) before it hands it over, and 2.8.0 left it to uid 100: the guide
(§ Upgrading) gives the hand-over by name to run in the 2.7.x image first,
measured on a copy of the test server's queue. Going forward again after
a rollback, a domain added under 2.7.x has its key only in the old tree:
copy its directory into `/data/dkim/keys` (removing `/data/dkim` instead
copies the old tree again and loses the keys of domains added under
2.8.x).