panel-dkim-signing stage 2: OpenDKIM removed
test / test (push) Canceled after 0s

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
+7 -9
View File
@@ -27,16 +27,14 @@ type mailStack struct {
Lists *inbound.Lists
DMARC *dmarc.Service
pf *postfix.Postfix
odk *domain.OpenDKIM
inboundEnabled bool
dmarcEnabled bool
}
func newMailStack(cfg config, st *store.Store) *mailStack {
pf := postfix.New(cfg.postfixDir)
odk := domain.NewOpenDKIM(cfg.opendkimDir)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
domains := domain.NewService(st, domain.NewKeys(cfg.dkimDir), apps, cfg.dkimSelectorDef)
// The panel's notifications are sent as postmaster@ the server's own name,
// and signed as it.
domains.SetServerName(cfg.hostname)
@@ -45,15 +43,15 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
return &mailStack{
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
Lists: inbound.NewLists(st, cfg.antispamDir),
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
pf: pf, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
}
}
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
// reloads both daemons — the same work as the Status page's Reload button.
// Resync rebuilds the selector table and Postfix's maps from SQLite and
// reloads Postfix — the same work as the Health page's Reload button.
func (m *mailStack) Resync() error {
if err := m.Domains.Resync(); err != nil {
return fmt.Errorf("opendkim resync: %w", err)
return fmt.Errorf("dkim resync: %w", err)
}
if err := m.Apps.Resync(); err != nil {
return fmt.Errorf("postfix resync: %w", err)
@@ -78,7 +76,6 @@ func (m *mailStack) Resync() error {
func (m *mailStack) skipReloadForTest() {
m.pf.SetReloadHook(func() error { return nil })
m.odk.SetReloadHook(func() error { return nil })
}
// resyncAfterRestore runs one mail-path Resync on the first boot after a
@@ -116,7 +113,8 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
TLSCertFile: cfg.tlsCertFile,
TLSServedCertFile: cfg.tlsServedCertFile,
TLSReloadInterval: cfg.tlsReloadInterval,
OpenDKIMSocket: cfg.opendkimSocket,
SignSocket: cfg.signSocket,
SignLocalSocket: cfg.signLocalSocket,
JournalSocket: cfg.journalSocket,
SenderSocket: cfg.senderSocket,
SessionIdleDays: cfg.sessionIdleDays,
+9 -22
View File
@@ -28,7 +28,6 @@ import (
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/logtail"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/syslogrelay"
)
func main() {
@@ -48,21 +47,12 @@ func main() {
}
showVersion := flag.Bool("version", false, "print version and exit")
relay := flag.String("syslog-relay", "", "relay syslog(3) datagrams sent to this socket path to stdout, and do nothing else")
flag.Parse()
if *showVersion {
fmt.Println(buildinfo.Version)
return
}
if *relay != "" {
// The container's /dev/log, for OpenDKIM (internal/syslogrelay;
// build/supervisord.conf, [program:syslog-relay]).
if err := syslogrelay.Run(*relay, os.Stdout); err != nil {
log.Fatal(err)
}
return
}
log.SetFlags(log.LstdFlags | log.LUTC)
log.SetPrefix("panel: ")
@@ -98,19 +88,17 @@ type config struct {
rateLimitMessagesPerIP int
rateLimitWindowSeconds int
// Read-only inputs to the panel's status page: the certificate Postfix
// serves and the two milter sockets it connects to. The defaults mirror
// build/postfix-config.sh, so the status page checks exactly what Postfix
// was configured with.
tlsCertFile string
opendkimSocket string
// Read-only input to the panel's status page: the certificate Postfix
// serves. The default mirrors build/postfix-config.sh, so the status
// page checks exactly what Postfix was configured with.
tlsCertFile string
// tlsServedCertFile is the certificate Postfix serves (its copy of
// tlsCertFile), read from postconf at the start; tlsReloadInterval is
// how often build/postfix-cert-reload.sh looks for a renewal.
tlsServedCertFile string
tlsReloadInterval time.Duration
opendkimDir string
dkimDir string
dkimSelectorDef string
saslDBPath string
@@ -184,11 +172,10 @@ func loadConfig() config {
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
tlsReloadInterval: time.Duration(envInt("TLS_RELOAD_INTERVAL_SECONDS", 86400)) * time.Second,
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
// Per-domain DKIM state (architecture.md § DKIM signing): the keys and
// the selector table, the layout entrypoint.sh prepares.
dkimDir: envDefault("DKIM_DIR", filepath.Join(dataDir, "dkim")),
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
// Application SASL accounts and the Postfix sender map (architecture.md §
@@ -419,7 +406,7 @@ func run() error {
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
{"sender-check", func(ctx context.Context) error { return serveSenderCheck(ctx, cfg, st) }},
{"dkim-signer", func(ctx context.Context) error { return serveSigner(ctx, cfg) }},
{"dkim-signer", func(ctx context.Context) error { return serveSigner(ctx, cfg, st) }},
{"log-tailer", func(ctx context.Context) error {
return logtail.Run(ctx, cfg.mailLog, st, func() int {
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
-15
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"log"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
@@ -21,20 +20,6 @@ func runNotifier(ctx context.Context, cfg config, st *store.Store) error {
ms := newMailStack(cfg, st)
dns := dnscheck.New(cfg.dnsResolvers)
// OpenDKIM's tables are rebuilt when a domain is added or deleted. On the
// first start of a version that signs the server's own name they do not
// have its line yet. A failure here costs the signature, not the message:
// OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, mlfi_eoh(): with no
// SigningTable match and Mode s (build/opendkim.conf) the message is
// accepted unsigned. A line whose key the KeyTable lacks or that does not
// load is another matter - dkimf_apply_signtable() < 0 there answers
// SMFIS_TEMPFAIL (assumptions A-47).
if rebuilt, err := ms.Domains.EnsureServerSigned(); err != nil {
log.Printf("notifier: sign as %s: %v", cfg.hostname, err)
} else if rebuilt {
log.Printf("notifier: OpenDKIM now signs mail of %s", cfg.hostname)
}
n := notify.New(st, notify.Checks{
Domain: func(d store.Domain) (dnscheck.Domain, error) {
rec, err := ms.Domains.DKIMRecord(d)
+2 -2
View File
@@ -127,7 +127,7 @@ func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
extract(t, archiveOf(t, seedPanelProject(t)), target)
cfg := panelConfig(t, target)
key := filepath.Join(cfg.dataDir, "opendkim", "keys", restoreDomain, "selfpost.private")
key := filepath.Join(cfg.dataDir, "dkim", "keys", restoreDomain, "selfpost.private")
f, err := os.OpenFile(key, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
t.Fatalf("open key: %v", err)
@@ -139,7 +139,7 @@ func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
t.Error("a refused restore ran its Resync")
return nil
})
want := "data/opendkim/keys/" + restoreDomain + "/selfpost.private"
want := "data/dkim/keys/" + restoreDomain + "/selfpost.private"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("the start on a damaged key = %v, want a refusal naming %s", err, want)
}
+7 -7
View File
@@ -85,9 +85,9 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
// The archive carries the daemon files, and the first boot after restore
// re-derives the maps from SQLite so they stay aligned with the database.
for path, want := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
} {
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
if err != nil {
@@ -268,10 +268,10 @@ func seedPanelProject(t *testing.T) string {
}
for path, content := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
} {
full := filepath.Join(dataDir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
+1 -2
View File
@@ -63,8 +63,7 @@ func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
}
}()
// The two tables the mail path itself goes by: whom Postfix lets send as
// what, and what OpenDKIM signs.
// The table the mail path itself goes by: whom Postfix lets send as what.
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
return milter.ServeSender(ctx, ln, bind, st)
}
+14 -5
View File
@@ -8,6 +8,7 @@ import (
"github.com/mixeme/selfpost/internal/milter"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// serveSigner runs the DKIM signer (internal/milter/sign.go) on its two
@@ -18,11 +19,19 @@ import (
// name. Postfix attaches both strictly (default_action=tempfail,
// build/postfix-config.sh), so while this role is not serving, mail waits.
//
// The keys and selectors come from the KeyTable under opendkimDir, which the
// panel writes for OpenDKIM too while both sign (docs/plans/panel-dkim-signing.md,
// stage 1).
func serveSigner(ctx context.Context, cfg config) error {
keys := milter.NewSignKeys(filepath.Join(cfg.opendkimDir, "KeyTable"), filepath.Join(cfg.opendkimDir, "keys"))
// 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.
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")
}
keys := milter.NewSignKeys(filepath.Join(cfg.dkimDir, "selectors"), filepath.Join(cfg.dkimDir, "keys"))
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
server := strings.ToLower(cfg.hostname)