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
+19 -11
View File
@@ -24,22 +24,28 @@ func (c *countingApps) Resync() error {
// A deletion that fails after the domain's row is gone says the domain is
// deleted, and runs every rebuild anyway: the sender map was left naming the
// purged logins when OpenDKIM's tables failed first (audit 2026-10-12, L17).
// The key stays while the tables may still name it.
// purged logins when the signing tables failed first (audit 2026-10-12, L17).
// The key stays while the selector table may still name it.
func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
apps := &countingApps{}
s, odk := newTestService(t, apps)
s, keys := newTestService(t, apps)
d, err := s.Add("gone.example")
if err != nil {
t.Fatal(err)
}
keyDir := filepath.Join(odk.keysDir, d.Name)
keyDir := filepath.Join(keys.keysDir, d.Name)
apps.resyncs = 0
odk.reload = func() error { return errors.New("supervisor away") }
// A directory where the table goes: no file can be renamed over it.
if err := os.Remove(keys.selectorsPath); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(keys.selectorsPath, "x"), 0o700); err != nil {
t.Fatal(err)
}
err = s.Delete(d.ID)
if !errors.Is(err, ErrDeletedIncomplete) {
t.Fatalf("Delete with OpenDKIM's reload failing = %v, want ErrDeletedIncomplete", err)
t.Fatalf("Delete with the selector table unwritable = %v, want ErrDeletedIncomplete", err)
}
if _, err := s.Get(d.ID); !errors.Is(err, store.ErrDomainNotFound) {
t.Errorf("the domain is still in the registry: %v", err)
@@ -53,7 +59,9 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
// Reload configuration finishes the deletion: the tables rebuilt, the
// key of a domain that is gone goes too; the others' stay.
odk.reload = func() error { return nil }
if err := os.RemoveAll(keys.selectorsPath); err != nil {
t.Fatal(err)
}
keep, err := s.Add("kept.example")
if err != nil {
t.Fatal(err)
@@ -64,7 +72,7 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
if _, err := os.Stat(keyDir); !os.IsNotExist(err) {
t.Errorf("the deleted domain's key is still there after Resync: %v", err)
}
if _, err := os.Stat(filepath.Join(odk.keysDir, keep.Name)); err != nil {
if _, err := os.Stat(filepath.Join(keys.keysDir, keep.Name)); err != nil {
t.Errorf("Resync removed a domain's key: %v", err)
}
}
@@ -73,16 +81,16 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
// rebuilt, so the key goes.
func TestDeleteWithTheSenderMapFailing(t *testing.T) {
apps := &countingApps{resyncErr: errors.New("postfix away")}
s, odk := newTestService(t, apps)
s, keys := newTestService(t, apps)
d, err := s.Add("gone.example")
if err != nil {
t.Fatal(err)
}
keyDir := filepath.Join(odk.keysDir, d.Name)
keyDir := filepath.Join(keys.keysDir, d.Name)
if err := s.Delete(d.ID); !errors.Is(err, ErrDeletedIncomplete) {
t.Fatalf("Delete with the sender map failing = %v, want ErrDeletedIncomplete", err)
}
if _, err := os.Stat(keyDir); !os.IsNotExist(err) {
t.Errorf("the key is still there after the tables were rebuilt: %v", err)
t.Errorf("the key is still there after the table was rebuilt: %v", err)
}
}
+10 -18
View File
@@ -1,8 +1,8 @@
// Package domain owns SelfPost's sending-domain model: per-domain DKIM key
// generation, the OpenDKIM KeyTable/SigningTable that drive signing, and the
// orchestration that keeps the SQLite registry, the on-disk keys and OpenDKIM
// in agreement (architecture.md § OpenDKIM). Key material lives under /data so
// it survives container restarts (architecture.md § OpenDKIM).
// generation, the selector table the panel's signer reads, and the
// orchestration that keeps the SQLite registry and the on-disk keys in
// agreement (architecture.md § DKIM signing). Key material lives under /data
// so it survives container restarts.
package domain
import (
@@ -32,23 +32,15 @@ func generateDKIMKey() (*rsa.PrivateKey, error) {
}
// writePrivateKeyPEM writes key to path as a PKCS#1 "RSA PRIVATE KEY" PEM,
// atomically and group-readable (0640). The file is owned by the panel user and
// read by OpenDKIM through the shared `selfpost` group (see build/opendkim.conf
// and entrypoint.sh); the parent directory carries setgid so the group is
// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes
// a half-written key.
//
// OpenDKIM 2.11.0~beta2, libopendkim/dkim.c, dkim_privkey_load(): a key that
// starts as PEM goes to OpenSSL's PEM_read_bio_PrivateKey(), which takes this
// PKCS#1 form; opendkim/opendkim.c, dkimf_add_signrequest(): a key file
// dkimf_securefile() finds insecure is only logged while RequireSafeKeys is no
// (build/opendkim.conf).
// atomically and readable by the panel user alone (0600): the panel's signer
// is the only reader (internal/milter, readKey()). The write is atomic (temp
// file + rename) so the signer never reads a half-written key.
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}
return writeFileAtomic(path, pem.EncodeToMemory(block), 0o640)
return writeFileAtomic(path, pem.EncodeToMemory(block), 0o600)
}
// loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by
@@ -79,7 +71,7 @@ type DKIMRecord struct {
}
// dkimRecord builds the published DKIM DNS record for a public key. The value
// mirrors what opendkim-genkey emits: v=DKIM1, sha256, RSA, and the public key
// is the one opendkim-genkey emitted: v=DKIM1, sha256, RSA, and the public key
// as base64-encoded SubjectPublicKeyInfo (PKIX) DER.
func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, error) {
der, err := x509.MarshalPKIXPublicKey(pub)
@@ -95,7 +87,7 @@ func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, er
// writeFileAtomic writes data to path via a temp file in the same directory
// followed by a rename, so readers only ever see the complete old or new file.
// It is the single safe-write primitive for DKIM keys and OpenDKIM tables
// It is the single safe-write primitive for DKIM keys and the selector table
// (security.md).
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
return atomicfile.Write(path, data, perm)
+2 -2
View File
@@ -21,8 +21,8 @@ func TestWriteLoadPrivateKeyRoundtrip(t *testing.T) {
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o640 {
t.Errorf("key perm = %o, want 0640", perm)
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("key perm = %o, want 0600", perm)
}
loaded, err := loadPrivateKeyPEM(path)
+234
View File
@@ -0,0 +1,234 @@
package domain
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/mixeme/selfpost/internal/configsafe"
)
// Keys manages the DKIM state on disk (architecture.md § DKIM signing): a
// signing key per name under keysDir, keysDir/<name>/<selector>.private, and
// the selector table that says, for every name the server signs, which of its
// keys signs. The panel's signer (internal/milter, SignKeys) reads both; it
// reads no database, so a page's long query never holds up a message.
type Keys struct {
keysDir string
selectorsPath string
}
// NewKeys builds a manager rooted at dir (typically /data/dkim), the layout
// entrypoint.sh prepares.
func NewKeys(dir string) *Keys {
return &Keys{
keysDir: filepath.Join(dir, "keys"),
selectorsPath: filepath.Join(dir, "selectors"),
}
}
// SigningDomain is one row's worth of signing configuration.
type SigningDomain struct {
Name string
Selector string
}
// KeyPath is the private-key file of a name and selector.
func KeyPath(keysDir, domainName, selector string) string {
return filepath.Join(keysDir, domainName, selector+".private")
}
func (o *Keys) keyPath(domainName, selector string) string {
return KeyPath(o.keysDir, domainName, selector)
}
// EnsureKey makes sure a signing key exists for the domain. An existing key is
// reused untouched — critical because overwriting it would silently invalidate
// the DKIM record already published in DNS (architecture.md § DKIM signing).
// Returns whether a new key was generated.
func (o *Keys) EnsureKey(domainName, selector string) (bool, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return false, err
}
path := o.keyPath(domainName, selector)
if _, err := os.Stat(path); err == nil {
return false, nil // reuse existing key
} else if !os.IsNotExist(err) {
return false, fmt.Errorf("stat dkim key: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return false, fmt.Errorf("create key dir: %w", err)
}
key, err := generateDKIMKey()
if err != nil {
return false, err
}
if err := writePrivateKeyPEM(path, key); err != nil {
return false, err
}
return true, nil
}
// RemoveKey deletes a domain's key directory (architecture.md § DKIM
// signing). A missing directory is not an error.
func (o *Keys) RemoveKey(domainName string) error {
if err := assertConfigSafe(domainName, "x"); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil {
return fmt.Errorf("remove key dir for %s: %w", domainName, err)
}
return nil
}
// KeyDomains lists the names that have a key directory.
func (o *Keys) KeyDomains() ([]string, error) {
entries, err := os.ReadDir(o.keysDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("list key dirs: %w", err)
}
var out []string
for _, e := range entries {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out, nil
}
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
// a domain export so the receiving instance signs with the same key and the
// DNS TXT record never has to change (architecture.md § Persistence). It
// re-marshals the parsed key rather than returning the raw file, so a
// malformed on-disk key is caught here.
func (o *Keys) ExportKey(domainName, selector string) ([]byte, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return nil, err
}
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return nil, err
}
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
return pem.EncodeToMemory(block), nil
}
// ImportKey writes an imported DKIM private key to disk for a domain
// (architecture.md § Persistence). The PEM is parsed and re-marshalled through
// the same writer used for generated keys, so only a well-formed PKCS#1 RSA
// key is ever stored. Unlike EnsureKey it overwrites: an import (re-)creates
// the domain with exactly this key, which is the whole point of keeping the
// published DNS record valid.
func (o *Keys) ImportKey(domainName, selector string, pemKey []byte) error {
if err := assertConfigSafe(domainName, selector); err != nil {
return err
}
block, _ := pem.Decode(pemKey)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return fmt.Errorf("import dkim key for %s: not a PKCS#1 RSA private key", domainName)
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("import dkim key for %s: %w", domainName, err)
}
path := o.keyPath(domainName, selector)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create key dir: %w", err)
}
return writePrivateKeyPEM(path, key)
}
// Record returns the published DKIM DNS record for a domain, recomputed from the
// private key on disk (product.md).
func (o *Keys) Record(domainName, selector string) (DKIMRecord, error) {
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return DKIMRecord{}, err
}
return dkimRecord(selector, domainName, &key.PublicKey)
}
// Rebuild rewrites the selector table from the full domain set. Full
// regeneration (rather than incremental edits) keeps the file a pure function
// of the registry, so add and delete share one idempotent path. The signer
// reads the file again when it changes; nothing is reloaded.
func (o *Keys) Rebuild(domains []SigningDomain) error {
table, err := renderSelectors(domains)
if err != nil {
return err
}
return writeFileAtomic(o.selectorsPath, table, 0o600)
}
// signs reports whether the selector table signs name with the key of
// selector. A table that does not exist yet signs nothing. The selector is
// part of the question because the panel shows and checks the record of one
// key; a table that names another would have the signer sign with a key whose
// record nobody is told to publish.
func (o *Keys) signs(name, selector string) (bool, error) {
data, err := os.ReadFile(o.selectorsPath)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("read %s: %w", filepath.Base(o.selectorsPath), err)
}
for _, line := range strings.Split(string(data), "\n") {
if line == name+" "+selector {
return true, nil
}
}
return false, nil
}
// renderSelectors builds the selector table for a domain set, a line
// "<name> <selector>" each, sorted by name so the output is deterministic.
// Every domain is re-checked for config-injection safety before being written
// (security.md) — validation upstream already guarantees this, but the table
// writer refuses to emit anything unsafe as a hard backstop.
func renderSelectors(domains []SigningDomain) ([]byte, error) {
sorted := append([]SigningDomain(nil), domains...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
var sb strings.Builder
for _, d := range sorted {
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
return nil, err
}
fmt.Fprintf(&sb, "%s %s\n", d.Name, d.Selector)
}
return []byte(sb.String()), nil
}
// keyForbidden is what may never appear in a name or a selector: white space
// and the newline that would end the selector table's field or line, and the
// path separators - the name and the selector are also the key's path - with
// the colon, which no DNS name holds either.
const keyForbidden = " \t\r\n:/\\"
// assertConfigSafe rejects any domain/selector value that could break out of a
// single table line or out of the key directory. Domains are already
// whitelisted to [a-z0-9.-] and selectors to a similar set before they reach
// here (security.md); this is defence in depth against a validation gap ever
// letting whitespace, a newline or a path separator through. A name of dots
// alone - ".." - would also climb out of keysDir.
func assertConfigSafe(domainName, selector string) error {
for _, f := range []struct{ what, value string }{
{"domain", domainName},
{"selector", selector},
} {
if err := configsafe.Token(f.what, f.value, keyForbidden); err != nil {
return fmt.Errorf("dkim: %w", err)
}
if strings.Trim(f.value, ".") == "" {
return fmt.Errorf("dkim: unsafe %s %q", f.what, f.value)
}
}
return nil
}
@@ -3,41 +3,26 @@ package domain
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestRenderTables(t *testing.T) {
keysDir := "/data/opendkim/keys"
func TestRenderSelectors(t *testing.T) {
// Deliberately out of order to exercise the deterministic sort.
domains := []SigningDomain{
{Name: "zeta.example", Selector: "selfpost"},
{Name: "alpha.example", Selector: "sel2"},
}
kt, st, err := renderTables(keysDir, domains)
got, err := renderSelectors(domains)
if err != nil {
t.Fatalf("renderTables: %v", err)
t.Fatalf("renderSelectors: %v", err)
}
wantKT := "alpha.example alpha.example:sel2:/data/opendkim/keys/alpha.example/sel2.private\n" +
"zeta.example zeta.example:selfpost:/data/opendkim/keys/zeta.example/selfpost.private\n"
if string(kt) != wantKT {
t.Errorf("KeyTable =\n%q\nwant\n%q", kt, wantKT)
if want := "alpha.example sel2\nzeta.example selfpost\n"; string(got) != want {
t.Errorf("selector table =\n%q\nwant\n%q", got, want)
}
wantST := "*@alpha.example alpha.example\n*@zeta.example zeta.example\n"
if string(st) != wantST {
t.Errorf("SigningTable =\n%q\nwant\n%q", st, wantST)
}
}
func TestRenderTablesEmpty(t *testing.T) {
kt, st, err := renderTables("/keys", nil)
if err != nil {
t.Fatalf("renderTables(nil): %v", err)
}
if len(kt) != 0 || len(st) != 0 {
t.Errorf("expected empty tables, got kt=%q st=%q", kt, st)
if empty, err := renderSelectors(nil); err != nil || len(empty) != 0 {
t.Errorf("renderSelectors(nil) = %q, %v; want an empty table", empty, err)
}
}
@@ -49,6 +34,9 @@ func TestAssertConfigSafeRejectsInjection(t *testing.T) {
{"../etc", "selfpost"},
{"", "selfpost"},
{"example.com", ""},
{"..", "selfpost"},
{".", "selfpost"},
{"example.com", ".."},
}
for _, b := range bad {
if err := assertConfigSafe(b.name, b.sel); err == nil {
@@ -60,21 +48,14 @@ func TestAssertConfigSafeRejectsInjection(t *testing.T) {
}
}
// newTestOpenDKIM builds a manager rooted at a temp dir with reload stubbed out.
func newTestOpenDKIM(t *testing.T) (*OpenDKIM, *int) {
// newTestKeys builds a manager rooted at a temp dir.
func newTestKeys(t *testing.T) *Keys {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "keys"), 0o750); err != nil {
t.Fatal(err)
}
reloads := 0
o := NewOpenDKIM(dir)
o.reload = func() error { reloads++; return nil }
return o, &reloads
return NewKeys(t.TempDir())
}
func TestEnsureKeyReusesExisting(t *testing.T) {
o, _ := newTestOpenDKIM(t)
o := newTestKeys(t)
created, err := o.EnsureKey("example.com", "selfpost")
if err != nil {
@@ -101,28 +82,41 @@ func TestEnsureKeyReusesExisting(t *testing.T) {
}
}
func TestRebuildWritesTablesAndReloads(t *testing.T) {
o, reloads := newTestOpenDKIM(t)
// The key and the table are the panel's alone: no other program reads them.
func TestRebuildWritesThePanelsOwnFiles(t *testing.T) {
o := newTestKeys(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
if err := o.Rebuild([]SigningDomain{{Name: "example.com", Selector: "selfpost"}}); err != nil {
t.Fatalf("Rebuild: %v", err)
}
if *reloads != 1 {
t.Errorf("reload called %d times, want 1", *reloads)
if signed, err := o.signs("example.com", "selfpost"); err != nil || !signed {
t.Errorf("signs after Rebuild = %v, %v", signed, err)
}
kt, err := os.ReadFile(o.keyTablePath)
if err != nil {
t.Fatal(err)
if signed, err := o.signs("example.com", "other"); err != nil || signed {
t.Errorf("signs with another selector = %v, %v", signed, err)
}
if !strings.Contains(string(kt), "example.com:selfpost:") {
t.Errorf("KeyTable missing entry: %q", kt)
if runtime.GOOS == "windows" {
return // no Unix modes
}
for path, want := range map[string]os.FileMode{
o.selectorsPath: 0o600,
o.keyPath("example.com", "selfpost"): 0o600,
filepath.Join(o.keysDir, "example.com"): 0o700,
} {
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := fi.Mode().Perm(); got != want {
t.Errorf("%s: mode %o, want %o", path, got, want)
}
}
}
func TestRemoveKey(t *testing.T) {
o, _ := newTestOpenDKIM(t)
o := newTestKeys(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
@@ -139,7 +133,7 @@ func TestRemoveKey(t *testing.T) {
}
func TestRecordFromWrittenKey(t *testing.T) {
o, _ := newTestOpenDKIM(t)
o := newTestKeys(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
-312
View File
@@ -1,312 +0,0 @@
package domain
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"github.com/mixeme/selfpost/internal/configsafe"
"github.com/mixeme/selfpost/internal/supervisor"
)
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
// (architecture.md § OpenDKIM): per-domain signing keys under keysDir and the
// KeyTable/SigningTable that map domains to those keys. After rewriting the
// tables it asks OpenDKIM to reload them.
type OpenDKIM struct {
keysDir string
keyTablePath string
signingTablePath string
// reload sends the running OpenDKIM a reload signal. It is a field so tests
// can substitute a no-op; the default drives supervisord (see reloadViaSupervisor).
reload func() error
}
// NewOpenDKIM builds a manager rooted at dir (typically /data/opendkim), the
// same layout entrypoint.sh prepares. The default reload path signals OpenDKIM
// through supervisord.
func NewOpenDKIM(dir string) *OpenDKIM {
return &OpenDKIM{
keysDir: filepath.Join(dir, "keys"),
keyTablePath: filepath.Join(dir, "KeyTable"),
signingTablePath: filepath.Join(dir, "SigningTable"),
reload: reloadViaSupervisor,
}
}
// SigningDomain is one row's worth of signing configuration.
type SigningDomain struct {
Name string
Selector string
}
// keyPath is the private-key path for a domain/selector, matching the KeyTable.
func (o *OpenDKIM) keyPath(domainName, selector string) string {
return filepath.Join(o.keysDir, domainName, selector+".private")
}
// EnsureKey makes sure a signing key exists for the domain. An existing key is
// reused untouched — critical because overwriting it would silently invalidate
// the DKIM record already published in DNS (architecture.md § OpenDKIM).
// Returns whether a new key was generated.
func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return false, err
}
path := o.keyPath(domainName, selector)
if _, err := os.Stat(path); err == nil {
return false, nil // reuse existing key
} else if !os.IsNotExist(err) {
return false, fmt.Errorf("stat dkim key: %w", err)
}
// setgid on keysDir (entrypoint.sh) makes the per-domain dir inherit the
// shared `selfpost` group so OpenDKIM can traverse into it.
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return false, fmt.Errorf("create key dir: %w", err)
}
key, err := generateDKIMKey()
if err != nil {
return false, err
}
if err := writePrivateKeyPEM(path, key); err != nil {
return false, err
}
return true, nil
}
// RemoveKey deletes a domain's key directory (architecture.md § OpenDKIM). A
// missing directory is not an error.
func (o *OpenDKIM) RemoveKey(domainName string) error {
if err := assertConfigSafe(domainName, "x"); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil {
return fmt.Errorf("remove key dir for %s: %w", domainName, err)
}
return nil
}
// KeyDomains lists the names that have a key directory.
func (o *OpenDKIM) KeyDomains() ([]string, error) {
entries, err := os.ReadDir(o.keysDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("list key dirs: %w", err)
}
var out []string
for _, e := range entries {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out, nil
}
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
// a domain export so the receiving instance signs with the same key and the
// DNS TXT record never has to change (architecture.md § Persistence). It
// re-marshals the parsed key rather than returning the raw file, so a
// malformed on-disk key is caught here.
func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return nil, err
}
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return nil, err
}
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
return pem.EncodeToMemory(block), nil
}
// ImportKey writes an imported DKIM private key to disk for a domain
// (architecture.md § Persistence). The PEM is parsed and re-marshalled through
// the same writer used for generated keys, so only a well-formed PKCS#1 RSA
// key is ever stored. Unlike EnsureKey it overwrites: an import (re-)creates
// the domain with exactly this key, which is the whole point of keeping the
// published DNS record valid.
func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error {
if err := assertConfigSafe(domainName, selector); err != nil {
return err
}
block, _ := pem.Decode(pemKey)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return fmt.Errorf("import dkim key for %s: not a PKCS#1 RSA private key", domainName)
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("import dkim key for %s: %w", domainName, err)
}
path := o.keyPath(domainName, selector)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create key dir: %w", err)
}
return writePrivateKeyPEM(path, key)
}
// Record returns the published DKIM DNS record for a domain, recomputed from the
// private key on disk (product.md).
func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) {
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return DKIMRecord{}, err
}
return dkimRecord(selector, domainName, &key.PublicKey)
}
// SigningTablePath is the SigningTable's location: the names this server
// signs for, which the sender check reads (internal/milter/sender.go).
func (o *OpenDKIM) SigningTablePath() string {
return o.signingTablePath
}
// Rebuild regenerates KeyTable and SigningTable from the full domain set and
// reloads OpenDKIM (architecture.md § OpenDKIM). Full regeneration (rather
// than incremental edits) keeps the files a pure function of the registry, so
// add and delete share one idempotent path. Both files are written atomically
// before the reload signal is sent.
func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
keyTable, signingTable, err := renderTables(o.keysDir, domains)
if err != nil {
return err
}
if err := writeFileAtomic(o.keyTablePath, keyTable, 0o640); err != nil {
return err
}
if err := writeFileAtomic(o.signingTablePath, signingTable, 0o640); err != nil {
return err
}
return o.reload()
}
// signs reports whether the tables on disk sign name with the key of
// selector: a line for the name in the SigningTable, and in the KeyTable the
// line that gives it that selector. Tables that do not exist yet sign nothing.
// The selector is part of the question because the panel shows and checks the
// record of one key; tables that name another would have OpenDKIM sign with a
// key whose record nobody is told to publish.
//
// OpenDKIM 2.11.0~beta2, opendkim/util.c, dkimf_mkregexp(): "*@name" is
// matched as ^.*@name$ (case aside), so it signs name and none of its
// subdomains (see Signers.Signs in internal/milter/sender.go).
func (o *OpenDKIM) signs(name, selector string) (bool, error) {
has := func(path, prefix string) (bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("read %s: %w", filepath.Base(path), err)
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, prefix) {
return true, nil
}
}
return false, nil
}
signed, err := has(o.signingTablePath, "*@"+name+" ")
if err != nil || !signed {
return false, err
}
return has(o.keyTablePath, name+" "+name+":"+selector+":")
}
// Reload asks OpenDKIM to re-read its tables without regenerating them. It
// backs the panel's manual reload button (architecture.md § Panel HTTP
// surface).
func (o *OpenDKIM) Reload() error {
return o.reload()
}
// SetReloadHook replaces how configuration is applied after a rebuild. Tests
// that cannot reach supervisord use this to verify file regeneration alone.
func (o *OpenDKIM) SetReloadHook(fn func() error) {
if fn != nil {
o.reload = fn
}
}
// renderTables builds the KeyTable and SigningTable byte contents for a domain
// set, sorted by name so the output is deterministic. Every domain is
// re-checked for shell/config-injection safety before being written
// (security.md) — validation upstream already guarantees this, but the table
// writer refuses to emit anything unsafe as a hard backstop.
func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTable []byte, err error) {
sorted := append([]SigningDomain(nil), domains...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
// OpenDKIM 2.11.0~beta2: KeyTable is a file: table, the key up to the
// first blank (opendkim/opendkim-db.c, dkimf_db_open(), DKIMF_DB_TYPE_FILE);
// opendkim/opendkim.c, dkimf_add_signrequest(), reads domain, selector and
// key from its value, split at ":" (opendkim-db.c, dkimf_db_datasplit()),
// a key starting with "/" being a file. SigningTable is refile:, a pattern
// up to the first blank and the key name after it (dkimf_db_open(), case
// DKIMF_DB_TYPE_REFILE).
var kt, st strings.Builder
for _, d := range sorted {
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
return nil, nil, err
}
keyName := d.Name // one key per domain; the domain name is a fine handle
// Absolute key path so OpenDKIM resolves it independently of its CWD.
keyFile := path.Join(keysDir, d.Name, d.Selector+".private")
// KeyTable: <key-name> <domain>:<selector>:<key-path>
fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile)
// SigningTable (refile): <address-pattern> <key-name>
fmt.Fprintf(&st, "*@%s %s\n", d.Name, keyName)
}
return []byte(kt.String()), []byte(st.String()), nil
}
// opendkimForbidden is what may never appear in a KeyTable or SigningTable
// value: whitespace and the newline that would end the line, and the colon and
// path separators that carry meaning inside a table entry. (OpenDKIM
// 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_datasplit(), splits a
// KeyTable value at ":"; the domain and selector are also the key's path.)
const opendkimForbidden = " \t\r\n:/\\"
// assertConfigSafe rejects any domain/selector value that could break out of a
// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors
// to a similar set before they reach here (security.md); this is defence in depth
// against a validation gap ever letting whitespace, a newline or a field
// separator through into a config file (security.md).
func assertConfigSafe(domainName, selector string) error {
for _, f := range []struct{ what, value string }{
{"domain", domainName},
{"selector", selector},
} {
if err := configsafe.Token(f.what, f.value, opendkimForbidden); err != nil {
return fmt.Errorf("opendkim: %w", err)
}
}
return nil
}
// reloadViaSupervisor asks supervisord (PID 1, running as root) to send the
// OpenDKIM process SIGUSR1, which makes it re-read KeyTable/SigningTable
// (opendkim's documented reload signal). The panel runs unprivileged and cannot
// signal another user's process directly, so it goes through the supervisor
// control socket, reachable via the shared `selfpost` group (security.md).
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (security.md).
//
// OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, dkimf_reloader(): SIGUSR1 sets
// reload when a configuration file was given (-x in build/supervisord.conf);
// dkimf_config_reload(), from mlfi_negotiate() at the next milter connection,
// loads the configuration and opens the tables again (opendkim.8, OPERATION).
// supervisor 4.2.5, supervisor/rpcinterface.py, signalProcess(), and
// process.py, signal(): kill() of the program's own pid - opendkim itself, run
// with -f (not forked into the background) and exec'd with no shell.
func reloadViaSupervisor() error {
return supervisor.Signal("USR1", "opendkim")
}
+34 -50
View File
@@ -6,37 +6,27 @@ import (
"testing"
)
func readTables(t *testing.T, odk *OpenDKIM) (keyTable, signingTable string) {
func readSelectors(t *testing.T, keys *Keys) string {
t.Helper()
kt, err := os.ReadFile(odk.keyTablePath)
data, err := os.ReadFile(keys.selectorsPath)
if err != nil {
t.Fatalf("read KeyTable: %v", err)
t.Fatalf("read the selector table: %v", err)
}
st, err := os.ReadFile(odk.signingTablePath)
if err != nil {
t.Fatalf("read SigningTable: %v", err)
}
return string(kt), string(st)
return string(data)
}
// The server's own name is signed like a sending domain - a key, a line in
// each table - without being one: no row in the registry, so no application
// can send as it.
// the selector table - without being one: no row in the registry, so no
// application can send as it.
func TestServerNameIsSignedWithoutBeingADomain(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{})
svc, keys := newTestService(t, &fakeApps{})
svc.SetServerName("mail.example.org")
if _, err := svc.Add("example.com"); err != nil {
t.Fatalf("add domain: %v", err)
}
kt, st := readTables(t, odk)
for _, want := range []string{"mail.example.org mail.example.org:selfpost:", "example.com example.com:selfpost:"} {
if !strings.Contains(kt, want) {
t.Errorf("KeyTable lacks %q:\n%s", want, kt)
}
}
if !strings.Contains(st, "*@mail.example.org mail.example.org\n") {
t.Errorf("SigningTable does not sign the server's own name:\n%s", st)
if got, want := readSelectors(t, keys), "example.com selfpost\nmail.example.org selfpost\n"; got != want {
t.Errorf("selector table =\n%q\nwant\n%q", got, want)
}
domains, err := svc.List()
if err != nil || len(domains) != 1 {
@@ -61,16 +51,15 @@ func TestServerNameIsSignedWithoutBeingADomain(t *testing.T) {
// When the hostname is itself a sending domain, there is one key and one line,
// the domain's; deleting the domain leaves the server's mail signed with it.
func TestServerNameThatIsAlsoADomain(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{})
svc, keys := newTestService(t, &fakeApps{})
svc.SetServerName("example.com")
d, err := svc.Add("example.com")
if err != nil {
t.Fatalf("add domain: %v", err)
}
_, st := readTables(t, odk)
if strings.Count(st, "*@example.com ") != 1 {
t.Errorf("the name is in the SigningTable more than once:\n%s", st)
if got := readSelectors(t, keys); got != "example.com selfpost\n" {
t.Errorf("selector table = %q, want the name once", got)
}
asDomain, err := svc.DKIMRecord(d)
if err != nil {
@@ -84,9 +73,8 @@ func TestServerNameThatIsAlsoADomain(t *testing.T) {
if err := svc.Delete(d.ID); err != nil {
t.Fatalf("delete domain: %v", err)
}
_, st = readTables(t, odk)
if !strings.Contains(st, "*@example.com example.com\n") {
t.Errorf("deleting the domain stopped the server's own mail being signed:\n%s", st)
if got := readSelectors(t, keys); got != "example.com selfpost\n" {
t.Errorf("deleting the domain stopped the server's own mail being signed: %q", got)
}
after, err := svc.ServerMail()
if err != nil || after.Record.Value != asDomain.Value {
@@ -94,48 +82,45 @@ func TestServerNameThatIsAlsoADomain(t *testing.T) {
}
}
// An installation upgraded to a version that signs the server's own name has
// tables without its line until something rebuilds them. The start does, once.
// The first start of 2.8.0 finds no selector table - OpenDKIM's tables were
// the earlier versions' - and an installation may lack the server's line.
// The start rebuilds, once.
func TestEnsureServerSignedRebuildsOnce(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{})
svc, keys := newTestService(t, &fakeApps{})
if _, err := svc.Add("example.com"); err != nil {
t.Fatal(err)
}
reloads := 0
odk.reload = func() error { reloads++; return nil }
if err := os.Remove(keys.selectorsPath); err != nil {
t.Fatal(err)
}
// The tables as the earlier version left them; now the name is known.
svc.SetServerName("mail.example.org")
if rebuilt, err := svc.EnsureServerSigned(); err != nil || !rebuilt {
t.Fatalf("first start: rebuilt = %v, %v; want a rebuild", rebuilt, err)
}
if _, st := readTables(t, odk); !strings.Contains(st, "*@mail.example.org mail.example.org\n") || !strings.Contains(st, "*@example.com ") {
t.Errorf("SigningTable after the rebuild:\n%s", st)
if got, want := readSelectors(t, keys), "example.com selfpost\nmail.example.org selfpost\n"; got != want {
t.Errorf("selector table after the rebuild = %q, want %q", got, want)
}
if rebuilt, err := svc.EnsureServerSigned(); err != nil || rebuilt {
t.Errorf("second start: rebuilt = %v, %v; want nothing to do", rebuilt, err)
}
if reloads != 1 {
t.Errorf("OpenDKIM was reloaded %d times, want once", reloads)
}
// A name that only begins like the server's is not it.
other, otherOdk := newTestService(t, &fakeApps{})
other, otherKeys := newTestService(t, &fakeApps{})
other.SetServerName("mail.example.org.evil.example")
if _, err := other.EnsureServerSigned(); err != nil {
t.Fatal(err)
}
other.SetServerName("mail.example.org")
if signed, err := otherOdk.signs("mail.example.org", "selfpost"); err != nil || signed {
if signed, err := otherKeys.signs("mail.example.org", "selfpost"); err != nil || signed {
t.Errorf("a longer name was taken for the server's: %v, %v", signed, err)
}
}
// Tables that sign the server's name with the key of another selector - the
// default selector was changed - are rebuilt: OpenDKIM must sign with the key
// A table that signs the server's name with the key of another selector - the
// default selector was changed - is rebuilt: the signer must sign with the key
// whose record the panel shows.
func TestEnsureServerSignedFollowsTheSelector(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{})
svc, keys := newTestService(t, &fakeApps{})
svc.SetServerName("mail.example.org")
if _, err := svc.EnsureServerSigned(); err != nil {
t.Fatal(err)
@@ -145,24 +130,23 @@ func TestEnsureServerSignedFollowsTheSelector(t *testing.T) {
if rebuilt, err := svc.EnsureServerSigned(); err != nil || !rebuilt {
t.Fatalf("after the selector changed: rebuilt = %v, %v; want a rebuild", rebuilt, err)
}
kt, _ := readTables(t, odk)
if !strings.Contains(kt, "mail.example.org mail.example.org:sp2027:") || strings.Contains(kt, ":selfpost:") {
t.Errorf("KeyTable after the rebuild:\n%s", kt)
if got := readSelectors(t, keys); got != "mail.example.org sp2027\n" {
t.Errorf("selector table after the rebuild = %q", got)
}
mail, err := svc.ServerMail()
if err != nil || mail.Selector != "sp2027" {
t.Errorf("the panel shows selector %q (%v), the tables sign with sp2027", mail.Selector, err)
t.Errorf("the panel shows selector %q (%v), the table signs with sp2027", mail.Selector, err)
}
}
// Without a hostname nothing is added and nothing is signed for it.
func TestNoServerName(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{})
svc, keys := newTestService(t, &fakeApps{})
if _, err := svc.Add("example.com"); err != nil {
t.Fatal(err)
}
if _, st := readTables(t, odk); strings.Count(st, "\n") != 1 {
t.Errorf("SigningTable has more than the one domain:\n%s", st)
if got := readSelectors(t, keys); got != "example.com selfpost\n" {
t.Errorf("selector table has more than the one domain: %q", got)
}
if _, err := svc.ServerMail(); err == nil {
t.Error("ServerMail without a hostname returned no error")
+45 -46
View File
@@ -29,14 +29,14 @@ type Applications interface {
}
// Service coordinates the places a sending domain lives: the SQLite registry,
// the on-disk DKIM keys and OpenDKIM's tables, plus — on deletion — the SASL
// database and Postfix sender map its applications touch. Callers (the web
// handlers) validate user input first; Service keeps the stores in agreement
// and drives the OpenDKIM/Postfix reloads (architecture.md § OpenDKIM,
// product.md).
// the on-disk DKIM keys and the selector table the signer reads, plus — on
// deletion — the SASL database and Postfix sender map its applications touch.
// Callers (the web handlers) validate user input first; Service keeps the
// stores in agreement and drives the Postfix reloads (architecture.md § DKIM
// signing, product.md).
type Service struct {
store *store.Store
odk *OpenDKIM
keys *Keys
apps Applications
selector string
// serverName is the server's own hostname, which the panel's notifications
@@ -48,7 +48,7 @@ type Service struct {
// SetServerName gives the service the server's own hostname. The panel sends
// its notifications from postmaster@ that name, and for a receiver to believe
// them the name is signed like a sending domain: it gets a DKIM key and a line
// in OpenDKIM's tables, without being a row of the registry - no application
// in the selector table, without being a row of the registry - no application
// can send as it. The name comes from SELFPOST_HOSTNAME, which the entrypoint
// has validated; it is not user input.
func (s *Service) SetServerName(hostname string) {
@@ -74,23 +74,23 @@ func (s *Service) ServerMail() (ServerMail, error) {
if err != nil {
return ServerMail{}, err
}
if _, err := s.odk.EnsureKey(s.serverName, selector); err != nil {
if _, err := s.keys.EnsureKey(s.serverName, selector); err != nil {
return ServerMail{}, err
}
rec, err := s.odk.Record(s.serverName, selector)
rec, err := s.keys.Record(s.serverName, selector)
if err != nil {
return ServerMail{}, err
}
return ServerMail{Name: s.serverName, Selector: selector, Record: rec}, nil
}
// EnsureServerSigned makes sure OpenDKIM's tables have the line for the
// server's own name, rebuilding them if they do not. The tables are otherwise
// rebuilt only when a domain is added or deleted, so an installation that was
// upgraded to a version with notifications would go on sending them unsigned
// until then, and one whose DKIM_SELECTOR_DEFAULT was changed would go on
// signing with the key of the old selector while the panel shows the record
// of the new. It reports whether it had to rebuild.
// EnsureServerSigned makes sure the selector table has the line for the
// server's own name, rebuilding it if it does not - or if there is no table
// yet, as on the first start of 2.8.0, whose table replaced OpenDKIM's. The
// table is otherwise rebuilt only when a domain is added or deleted, so an
// installation whose DKIM_SELECTOR_DEFAULT was changed would go on signing
// with the key of the old selector while the panel shows the record of the
// new. It reports whether it had to rebuild.
func (s *Service) EnsureServerSigned() (bool, error) {
if s.serverName == "" {
return false, nil
@@ -99,7 +99,7 @@ func (s *Service) EnsureServerSigned() (bool, error) {
if err != nil {
return false, err
}
signed, err := s.odk.signs(s.serverName, selector)
signed, err := s.keys.signs(s.serverName, selector)
if err != nil {
return false, err
}
@@ -129,8 +129,8 @@ func (s *Service) serverSelector() (string, error) {
// DKIM_SELECTOR_DEFAULT); it is operator-configured, not user input. apps is
// used only on deletion, to clear the SASL accounts and sender-map bindings of
// the domain's applications.
func NewService(st *store.Store, odk *OpenDKIM, apps Applications, selectorDefault string) *Service {
return &Service{store: st, odk: odk, apps: apps, selector: selectorDefault}
func NewService(st *store.Store, keys *Keys, apps Applications, selectorDefault string) *Service {
return &Service{store: st, keys: keys, apps: apps, selector: selectorDefault}
}
// List returns all domains with application counts (product.md).
@@ -144,7 +144,7 @@ func (s *Service) Get(id int64) (store.Domain, error) {
}
// Add registers a new sending domain: it records the row, ensures a DKIM key
// exists on disk, and regenerates + reloads the OpenDKIM tables (product.md).
// exists on disk, and regenerates the selector table (product.md).
// name must already be normalised and validated by the caller. A duplicate
// returns store.ErrDomainExists.
//
@@ -152,15 +152,15 @@ func (s *Service) Get(id int64) (store.Domain, error) {
// arbiter of "already exists" (avoiding a check-then-act race). An existing
// on-disk key is reused rather than overwritten, so re-adding a domain whose DB
// row was lost keeps its published DNS record valid. If key generation or the
// OpenDKIM rebuild fails, the row is rolled back so we never leave a registered
// domain that OpenDKIM cannot sign.
// table's rebuild fails, the row is rolled back so we never leave a registered
// domain the signer cannot sign for.
func (s *Service) Add(name string) (store.Domain, error) {
d, err := s.store.AddDomain(name, s.selector)
if err != nil {
return store.Domain{}, err
}
if _, err := s.odk.EnsureKey(d.Name, d.DKIMSelector); err != nil {
if _, err := s.keys.EnsureKey(d.Name, d.DKIMSelector); err != nil {
s.rollbackAdd(d.ID)
return store.Domain{}, err
}
@@ -181,10 +181,10 @@ func (s *Service) rollbackAdd(id int64) {
// Delete removes a domain and everything bound to it (product.md). The
// order matters: the applications' SASL accounts are cleared first, while their
// logins are still in the registry; then the registry rows (applications and
// their addresses) go via the DB cascade; then the OpenDKIM tables and the
// Postfix sender map are rebuilt from what remains — so OpenDKIM stops signing
// and Postfix stops authorising the domain's senders — before the DKIM key is
// deleted.
// their addresses) go via the DB cascade; then the selector table and the
// Postfix sender map are rebuilt from what remains — so the signer stops
// signing and Postfix stops authorising the domain's senders — before the
// DKIM key is deleted.
func (s *Service) Delete(id int64) error {
d, err := s.store.GetDomain(id)
if err != nil {
@@ -204,26 +204,26 @@ func (s *Service) Delete(id int64) error {
return err
}
// From here the domain is deleted, whatever fails after: each rebuild
// runs - the sender map was left with the purged logins when OpenDKIM's
// failed first - and a failure says the domain is gone (audit
// 2026-10-12, L17).
// runs - the sender map was left with the purged logins when the
// signing tables failed first - and a failure says the domain is gone
// (audit 2026-10-12, L17).
var errs []error
tablesRebuilt := true
if err := s.resync(); err != nil {
tablesRebuilt = false
errs = append(errs, fmt.Errorf("rebuild OpenDKIM's tables: %w", err))
errs = append(errs, fmt.Errorf("rebuild the selector table: %w", err))
}
if err := s.apps.Resync(); err != nil {
errs = append(errs, fmt.Errorf("rebuild the sender map: %w", err))
}
// A sending domain named like the server shared its key with the server's
// own mail. The resync above kept the name in the tables under the default
// own mail. The resync above kept the name in the table under the default
// selector: the same key if that was the domain's selector, a new one -
// with a new record to publish, which Settings shows - if it was not.
// Either way the directory holds the key now in use. And while the tables
// Either way the directory holds the key now in use. And while the table
// may still name the deleted domain's key, the key stays.
if d.Name != s.serverName && tablesRebuilt {
if err := s.odk.RemoveKey(d.Name); err != nil {
if err := s.keys.RemoveKey(d.Name); err != nil {
errs = append(errs, fmt.Errorf("remove its DKIM key: %w", err))
}
}
@@ -240,7 +240,7 @@ var ErrDeletedIncomplete = errors.New("domain deleted, but the mail configuratio
// DKIMRecord returns the DNS TXT record to publish for a domain (product.md).
func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
return s.odk.Record(d.Name, d.DKIMSelector)
return s.keys.Record(d.Name, d.DKIMSelector)
}
// RateLimit returns the domain-level differentiated rate limit (guide § Rate
@@ -263,13 +263,12 @@ func (s *Service) ClearRateLimit(domainID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
}
// Resync regenerates the OpenDKIM tables from the registry and reloads
// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP
// surface) and doubles as a recovery path if the tables ever drift from the
// database.
// Resync regenerates the selector table from the registry. It backs the
// manual reload button (architecture.md § Panel HTTP surface) and doubles as
// a recovery path if the table ever drifts from the database.
//
// It also removes the key directories of names no domain has and that are
// not the server's own: what a deletion left when the tables could not be
// not the server's own: what a deletion left when the table could not be
// rebuilt (Delete keeps the key then). The "Reload configuration" the page
// points to after such a deletion finishes it here (review of audit stage 4).
func (s *Service) Resync() error {
@@ -284,13 +283,13 @@ func (s *Service) Resync() error {
for _, d := range domains {
keep[d.Name] = true
}
names, err := s.odk.KeyDomains()
names, err := s.keys.KeyDomains()
if err != nil {
return err
}
for _, name := range names {
if !keep[name] {
if err := s.odk.RemoveKey(name); err != nil {
if err := s.keys.RemoveKey(name); err != nil {
return fmt.Errorf("remove the key of %s, a domain that is gone: %w", name, err)
}
}
@@ -298,7 +297,7 @@ func (s *Service) Resync() error {
return nil
}
// resync rebuilds KeyTable/SigningTable from the current domain set and reloads.
// resync rebuilds the selector table from the current domain set.
func (s *Service) resync() error {
domains, err := s.store.ListDomains()
if err != nil {
@@ -313,12 +312,12 @@ func (s *Service) resync() error {
}
}
// The server's own name, for the panel's notifications - unless it is a
// sending domain already and so in the tables above.
// sending domain already and so in the table above.
if s.serverName != "" && !registered {
if _, err := s.odk.EnsureKey(s.serverName, s.selector); err != nil {
if _, err := s.keys.EnsureKey(s.serverName, s.selector); err != nil {
return fmt.Errorf("dkim key for %s: %w", s.serverName, err)
}
signing = append(signing, SigningDomain{Name: s.serverName, Selector: s.selector})
}
return s.odk.Rebuild(signing)
return s.keys.Rebuild(signing)
}
+3 -3
View File
@@ -57,7 +57,7 @@ func (s *Service) Export(id int64) (DomainExport, error) {
if err != nil {
return DomainExport{}, err
}
pem, err := s.odk.ExportKey(d.Name, d.DKIMSelector)
pem, err := s.keys.ExportKey(d.Name, d.DKIMSelector)
if err != nil {
return DomainExport{}, fmt.Errorf("export DKIM key for %s: %w", d.Name, err)
}
@@ -109,7 +109,7 @@ func (s *Service) Export(id int64) (DomainExport, error) {
// Import re-creates a domain from an export file on this instance
// (architecture.md § Persistence): it stores the imported DKIM key (so the
// published DNS record needs no change), registers the domain and rebuilds the
// OpenDKIM tables, then re-creates each application with its working password
// selector table, then re-creates each application with its working password
// and rebuilds the Postfix sender map.
//
// exp.Domain must already be normalised and validated by the caller
@@ -133,7 +133,7 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
return store.Domain{}, err // ErrDomainExists surfaces to the caller
}
if err := s.odk.ImportKey(d.Name, d.DKIMSelector, []byte(exp.DKIMPrivateKey)); err != nil {
if err := s.keys.ImportKey(d.Name, d.DKIMSelector, []byte(exp.DKIMPrivateKey)); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
+5 -6
View File
@@ -43,18 +43,17 @@ func (f *fakeApps) ImportApplication(domainID int64, login, mode string, address
return nil
}
// newTestService builds a Service over a fresh SQLite store and OpenDKIM tree in
// a temp dir, with the OpenDKIM reload signal stubbed out.
func newTestService(t *testing.T, apps Applications) (*Service, *OpenDKIM) {
// newTestService builds a Service over a fresh SQLite store and key tree in a
// temp dir.
func newTestService(t *testing.T, apps Applications) (*Service, *Keys) {
t.Helper()
st, err := store.Open(t.TempDir() + "/selfpost.db")
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
odk := NewOpenDKIM(t.TempDir())
odk.reload = func() error { return nil }
return NewService(st, odk, apps, "selfpost"), odk
keys := NewKeys(t.TempDir())
return NewService(st, keys, apps, "selfpost"), keys
}
func TestExportImportRoundTrip(t *testing.T) {