panel-dkim-signing: the queue by name, and findings of the reviews
test / test (push) Canceled after 0s

The first rollout of faa3c11 to the test server crash-looped: without
opendkim the image's package accounts got other numbers (postfix 101 ->
100, postdrop 103 -> 102), the queue under /data kept the old ones, and
postsuper, which "postfix check" runs as the mail owner
(src/postsuper/postsuper.c:1442), could not open it. Rolled back within
five minutes. The entrypoint now hands an existing queue to Postfix's users
by name before postfix-config.sh, and again after it (own_queue).

Technical review (F1-F10):
- F1 high: a backup of an upgraded installation carried /data/opendkim, a
  restore left it root's, and the panel refused every start. The tree is
  left out of archives, and the entrypoint makes it the panel's alone
  (0700/0600; also the security review's F3 - nothing of 2.8 needs the
  group any more; a 2.7.x start re-normalises it).
- F2: List-Unsubscribe-Post is signed (RFC 8058); Sender too (security F1).
- F3: SetServerName keeps the name in lower case - a key written under
  "Mail.Example.com" was never found. Only the DKIM side: the SASL realm
  is compared as written.
- F4: the signer rewrites the selector table at every start
  (WriteSelectors, replacing EnsureServerSigned), and tries again every
  10 s while a rewrite fails.
- F5: e2e signature_over_awkward_headers (folded, tab-led, no space, two
  To:, 8-bit body, List-Unsubscribe-Post) checked by dkimpy;
  TestStartsOnA27Data starts on a 2.7 /data (old key tree, a queue of
  foreign numbers) and checks the copied key signs, the old tree's modes
  and the queue's owners; unit tests for repeated headers and the header
  limit.
- F6: the table's writers are serialised (tableMu).
- F7: the table is read for every message; keys the table no longer names
  are dropped from memory.
- F8: the signature's value brings its own space (SMFIP_HDR_LEADSPC gets
  none from Postfix, cleanup_milter.c:1085).
- F9: a body line over 998 bytes is logged with its domain: Postfix's SMTP
  client breaks it after signing (smtp_proto.c:1266-1274).
- F10: the keys are copied while /data/dkim holds none (not only when it
  is absent), after the old tree is made the panel's (an install from 2.6
  had another uid); the plan's rollback note says what a rollback keeps.

Security review: F2 a header section over 1 MiB is refused (552 5.3.4)
and the kept headers freed once the block is built - fifty connections of
header-only messages could take the panel down; F5 the shown headers a
message has (Subject, Date, To, Cc, Reply-To, Sender) are over-signed,
only when present, since cleanup adds a missing Date: after the milters.
F1 (holding Sender: to the login like From:) changes what is refused and
waits for the owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-16 01:44:59 +03:00
co-authored by Claude Opus 5.5
parent faa3c11ae7
commit c2f63d2789
16 changed files with 617 additions and 159 deletions
+31 -9
View File
@@ -5,12 +5,17 @@ import (
"log"
"path/filepath"
"strings"
"time"
"github.com/mixeme/selfpost/internal/milter"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// rewriteRetry is how often the signer tries again to write the selector
// table when the write at its start failed.
const rewriteRetry = 10 * time.Second
// serveSigner runs the DKIM signer (internal/milter/sign.go) on its two
// sockets: signSocket for what applications send on 465/587, signed with the
// key of the domain the login belongs to - the sender_login_maps Postfix and
@@ -20,16 +25,33 @@ import (
// build/postfix-config.sh), so while this role is not serving, mail waits.
//
// The keys and selectors come from the key tree and the selector table under
// dkimDir (internal/domain, Keys). Before it serves, the table is made to
// hold the server's own name - and written at all on the first start of
// 2.8.0, whose table replaced OpenDKIM's: Postfix waits for these sockets
// (build/postfix-wrapper.sh), so no message meets the table before it.
// dkimDir (internal/domain, Keys). Before it serves, the table is rewritten
// from the registry (WriteSelectors): Postfix waits for these sockets
// (build/postfix-wrapper.sh), so no message meets a table from before. A
// rewrite that fails is tried again every rewriteRetry until it succeeds;
// meanwhile what the signer finds no key for it defers, and the panel's
// pages - Reload configuration among them - stay up.
func serveSigner(ctx context.Context, cfg config, st *store.Store) error {
if rebuilt, err := newMailStack(cfg, st).Domains.EnsureServerSigned(); err != nil {
// Mail waits: what the signer cannot find a key for it defers.
log.Printf("dkim-signer: the selector table: %v", err)
} else if rebuilt {
log.Printf("dkim-signer: the selector table was rewritten from the registry")
domains := newMailStack(cfg, st).Domains
if err := domains.WriteSelectors(); err != nil {
log.Printf("dkim-signer: the selector table: %v - trying again every %s", err, rewriteRetry)
go func() {
tick := time.NewTicker(rewriteRetry)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
if err := domains.WriteSelectors(); err != nil {
log.Printf("dkim-signer: the selector table: %v", err)
continue
}
log.Printf("dkim-signer: the selector table is written")
return
}
}
}()
}
keys := milter.NewSignKeys(filepath.Join(cfg.dkimDir, "selectors"), filepath.Join(cfg.dkimDir, "keys"))
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())