Phase 3: sending domains + per-domain OpenDKIM signing
Add/list/delete of sending domains with per-domain DKIM keys and the OpenDKIM tables that drive signing (spec 6, 7.2.2-4, 7.2.10). internal/domain: - Pure-Go RSA-2048 keygen; PKCS#1 PEM written atomically at 0640; the published DNS TXT record is derived from the key on disk (single source of truth) rather than persisted. No os/exec for key generation. - KeyTable/SigningTable fully regenerated from the registry on every add/delete (idempotent), written atomically; SigningTable via refile: with *@domain, KeyTable with absolute key paths. Table writer refuses any unsafe character as a backstop (spec 7.6.4). - Reload without root: the unprivileged panel signals OpenDKIM through supervisord (`supervisorctl signal USR1 opendkim`, fixed args, no shell, no user input — spec 7.6.3). An existing key is reused, never overwritten, so re-adding a domain keeps its published DNS valid. - Service orchestrates registry -> key -> table rebuild -> reload, with rollback of the row if a downstream step fails; delete cascades apps via the DB FK and removes the key + table entries. Infra: - Shared `selfpost` group bridges panel (writes keys) and opendkim (reads them); /data/opendkim is setgid so panel-created files inherit the group, keys are 0640, RequireSafeKeys is disabled by design. - opendkim.conf moves from verify-only (Mode v) to signing (Mode s). - entrypoint.sh normalises the DKIM tree on every start (ownership, setgid, perms, empty tables before opendkim starts) — self-healing after a restore. - supervisord control socket opened to the `selfpost` group so the panel can request the reload. web/store: - Strict domain-name validation (whitelist [a-z0-9.-], DNS shape, >=2 labels), lower-case normalisation (spec 7.6.2). - Domain queries with application counts; delete relies on ON DELETE CASCADE. Dashboard lists domains + add form; domain page shows the DKIM record; a dedicated confirm page warns about the app cascade before deletion (spec 7.2.4); manual reload button (spec 7.2.12, OpenDKIM side; Postfix reload lands in Phase 5). - Authenticated routes moved to a sub-mux using Go 1.22 method/wildcard patterns. Tests: validateDomain, DKIM keygen/record roundtrip, table rendering + injection-safety, key reuse, store cascade. Verified on the dev server: gofmt/vet/test green, image builds, container e2e (add/delete a domain, DKIM record shown, OpenDKIM reads panel keys and reloads, keys and tables persist across a restart). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// 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 (spec 4.1, 6). Key material lives under /data so it survives
|
||||
// container restarts (spec 6.1, 9).
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// dkimKeyBits is the RSA key size for DKIM signing keys. 2048 is the DKIM
|
||||
// interoperability sweet spot: strong, and short enough that the published
|
||||
// public key still fits comfortably in a DNS TXT record.
|
||||
const dkimKeyBits = 2048
|
||||
|
||||
// generateDKIMKey creates a fresh RSA private key for signing a domain.
|
||||
func generateDKIMKey() (*rsa.PrivateKey, error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, dkimKeyBits)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate dkim key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
|
||||
// loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by
|
||||
// writePrivateKeyPEM. It is used to recompute the public DNS record on demand,
|
||||
// keeping the private key file the single source of truth (spec 7.2.10).
|
||||
func loadPrivateKeyPEM(path string) (*rsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil || block.Type != "RSA PRIVATE KEY" {
|
||||
return nil, fmt.Errorf("dkim key %s: not a PKCS#1 RSA private key", path)
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dkim key %s: %w", path, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// DKIMRecord is the DNS TXT record a user must publish for a domain (spec 7.2.10).
|
||||
type DKIMRecord struct {
|
||||
// Name is the record's host, e.g. "selfpost._domainkey.example.com".
|
||||
Name string
|
||||
// Value is the TXT payload, e.g. "v=DKIM1; h=sha256; k=rsa; p=MIIB...".
|
||||
Value string
|
||||
}
|
||||
|
||||
// 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
|
||||
// as base64-encoded SubjectPublicKeyInfo (PKIX) DER.
|
||||
func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, error) {
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return DKIMRecord{}, fmt.Errorf("marshal dkim public key: %w", err)
|
||||
}
|
||||
p := base64.StdEncoding.EncodeToString(der)
|
||||
return DKIMRecord{
|
||||
Name: fmt.Sprintf("%s._domainkey.%s", selector, domainName),
|
||||
Value: fmt.Sprintf("v=DKIM1; h=sha256; k=rsa; p=%s", p),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// (spec 7.6.4).
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp in %s: %w", dir, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temp %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("chmod temp %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("sync temp %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp %s: %w", tmpName, err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err)
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteLoadPrivateKeyRoundtrip(t *testing.T) {
|
||||
key, err := generateDKIMKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generateDKIMKey: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "selfpost.private")
|
||||
if err := writePrivateKeyPEM(path, key); err != nil {
|
||||
t.Fatalf("writePrivateKeyPEM: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o640 {
|
||||
t.Errorf("key perm = %o, want 0640", perm)
|
||||
}
|
||||
|
||||
loaded, err := loadPrivateKeyPEM(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadPrivateKeyPEM: %v", err)
|
||||
}
|
||||
if loaded.N.Cmp(key.N) != 0 || loaded.E != key.E {
|
||||
t.Error("loaded key does not match generated key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPrivateKeyRejectsGarbage(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.private")
|
||||
if err := os.WriteFile(path, []byte("not a pem"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadPrivateKeyPEM(path); err == nil {
|
||||
t.Error("expected error for non-PEM key file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDKIMRecord(t *testing.T) {
|
||||
key, err := generateDKIMKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, err := dkimRecord("selfpost", "example.com", &key.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("dkimRecord: %v", err)
|
||||
}
|
||||
if rec.Name != "selfpost._domainkey.example.com" {
|
||||
t.Errorf("record name = %q", rec.Name)
|
||||
}
|
||||
for _, want := range []string{"v=DKIM1", "h=sha256", "k=rsa", "p="} {
|
||||
if !strings.Contains(rec.Value, want) {
|
||||
t.Errorf("record value %q missing %q", rec.Value, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "f")
|
||||
if err := writeFileAtomic(path, []byte("one"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeFileAtomic(path, []byte("two"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "two" {
|
||||
t.Errorf("content = %q, want %q", got, "two")
|
||||
}
|
||||
// No stray temp files left behind in the directory.
|
||||
entries, _ := os.ReadDir(filepath.Dir(path))
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("expected 1 file after atomic writes, found %d", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
|
||||
// (spec 6): 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 (spec 6.1). 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 (spec 6.5). 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
|
||||
}
|
||||
|
||||
// Record returns the published DKIM DNS record for a domain, recomputed from the
|
||||
// private key on disk (spec 7.2.10).
|
||||
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)
|
||||
}
|
||||
|
||||
// Rebuild regenerates KeyTable and SigningTable from the full domain set and
|
||||
// reloads OpenDKIM (spec 6.2). 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()
|
||||
}
|
||||
|
||||
// Reload asks OpenDKIM to re-read its tables without regenerating them. It backs
|
||||
// the panel's manual reload button (spec 7.2.12).
|
||||
func (o *OpenDKIM) Reload() error {
|
||||
return o.reload()
|
||||
}
|
||||
|
||||
// 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 (spec
|
||||
// 7.6.4) — 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 })
|
||||
|
||||
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 := filepath.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
|
||||
}
|
||||
|
||||
// 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 (spec 7.6.2); this is defence in depth
|
||||
// against a validation gap ever letting whitespace, a newline or a field
|
||||
// separator through into a config file (spec 7.6.4).
|
||||
func assertConfigSafe(domainName, selector string) error {
|
||||
for _, v := range []string{domainName, selector} {
|
||||
if v == "" {
|
||||
return fmt.Errorf("opendkim: empty domain or selector")
|
||||
}
|
||||
if strings.ContainsAny(v, " \t\r\n:/\\") {
|
||||
return fmt.Errorf("opendkim: unsafe character in %q", v)
|
||||
}
|
||||
}
|
||||
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 (spec 7.6.3, 7.6.8).
|
||||
//
|
||||
// Arguments are fixed literals — no user input is interpolated into the command,
|
||||
// and it never goes through a shell (spec 7.6.3).
|
||||
func reloadViaSupervisor() error {
|
||||
cmd := exec.Command("supervisorctl",
|
||||
"-c", "/etc/supervisor/supervisord.conf",
|
||||
"signal", "USR1", "opendkim")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload opendkim via supervisor: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderTables(t *testing.T) {
|
||||
keysDir := "/data/opendkim/keys"
|
||||
// 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)
|
||||
if err != nil {
|
||||
t.Fatalf("renderTables: %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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertConfigSafeRejectsInjection(t *testing.T) {
|
||||
bad := []struct{ name, sel string }{
|
||||
{"exa mple.com", "selfpost"},
|
||||
{"example.com\nInject yes", "selfpost"},
|
||||
{"example.com", "sel:evil"},
|
||||
{"../etc", "selfpost"},
|
||||
{"", "selfpost"},
|
||||
{"example.com", ""},
|
||||
}
|
||||
for _, b := range bad {
|
||||
if err := assertConfigSafe(b.name, b.sel); err == nil {
|
||||
t.Errorf("assertConfigSafe(%q,%q) = nil, want error", b.name, b.sel)
|
||||
}
|
||||
}
|
||||
if err := assertConfigSafe("example.com", "selfpost"); err != nil {
|
||||
t.Errorf("assertConfigSafe of a clean pair errored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestOpenDKIM builds a manager rooted at a temp dir with reload stubbed out.
|
||||
func newTestOpenDKIM(t *testing.T) (*OpenDKIM, *int) {
|
||||
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
|
||||
}
|
||||
|
||||
func TestEnsureKeyReusesExisting(t *testing.T) {
|
||||
o, _ := newTestOpenDKIM(t)
|
||||
|
||||
created, err := o.EnsureKey("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("expected a new key to be created")
|
||||
}
|
||||
first, err := os.ReadFile(o.keyPath("example.com", "selfpost"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created, err = o.EnsureKey("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey (second): %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Error("expected existing key to be reused, not regenerated")
|
||||
}
|
||||
second, _ := os.ReadFile(o.keyPath("example.com", "selfpost"))
|
||||
if string(first) != string(second) {
|
||||
t.Error("key file changed on reuse — published DNS record would break")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildWritesTablesAndReloads(t *testing.T) {
|
||||
o, reloads := newTestOpenDKIM(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)
|
||||
}
|
||||
kt, err := os.ReadFile(o.keyTablePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(kt), "example.com:selfpost:") {
|
||||
t.Errorf("KeyTable missing entry: %q", kt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveKey(t *testing.T) {
|
||||
o, _ := newTestOpenDKIM(t)
|
||||
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := o.RemoveKey("example.com"); err != nil {
|
||||
t.Fatalf("RemoveKey: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(o.keysDir, "example.com")); !os.IsNotExist(err) {
|
||||
t.Error("key directory still present after RemoveKey")
|
||||
}
|
||||
// Removing a non-existent key is not an error.
|
||||
if err := o.RemoveKey("example.com"); err != nil {
|
||||
t.Errorf("RemoveKey on missing dir errored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFromWrittenKey(t *testing.T) {
|
||||
o, _ := newTestOpenDKIM(t)
|
||||
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, err := o.Record("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
if rec.Name != "selfpost._domainkey.example.com" {
|
||||
t.Errorf("record name = %q", rec.Name)
|
||||
}
|
||||
if !strings.HasPrefix(rec.Value, "v=DKIM1;") {
|
||||
t.Errorf("record value = %q", rec.Value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// Service coordinates the three places a sending domain lives: the SQLite
|
||||
// registry, the on-disk DKIM keys and OpenDKIM's tables. Callers (the web
|
||||
// handlers) validate user input first; Service keeps the three stores in
|
||||
// agreement and drives the OpenDKIM reload (spec 6, 7.2.2-4, 7.2.10).
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
odk *OpenDKIM
|
||||
selector string
|
||||
}
|
||||
|
||||
// NewService builds the domain service. selectorDefault is the DKIM selector
|
||||
// assigned to new domains (spec 8: DKIM_SELECTOR_DEFAULT); it is
|
||||
// operator-configured, not user input.
|
||||
func NewService(st *store.Store, odk *OpenDKIM, selectorDefault string) *Service {
|
||||
return &Service{store: st, odk: odk, selector: selectorDefault}
|
||||
}
|
||||
|
||||
// List returns all domains with application counts (spec 7.2.2).
|
||||
func (s *Service) List() ([]store.Domain, error) {
|
||||
return s.store.ListDomains()
|
||||
}
|
||||
|
||||
// Get returns one domain by id (store.ErrDomainNotFound if absent).
|
||||
func (s *Service) Get(id int64) (store.Domain, error) {
|
||||
return s.store.GetDomain(id)
|
||||
}
|
||||
|
||||
// Add registers a new sending domain: it records the row, ensures a DKIM key
|
||||
// exists on disk, and regenerates + reloads the OpenDKIM tables (spec 7.2.3).
|
||||
// name must already be normalised and validated by the caller. A duplicate
|
||||
// returns store.ErrDomainExists.
|
||||
//
|
||||
// The registry row is written first so its UNIQUE constraint is the single
|
||||
// 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.
|
||||
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 {
|
||||
s.rollbackAdd(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
if err := s.resync(); err != nil {
|
||||
s.rollbackAdd(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// rollbackAdd best-effort removes a half-created domain after a downstream
|
||||
// failure. Errors here are logged by the caller's returned error path; the key
|
||||
// (if freshly generated) is left in place harmlessly and reused on retry.
|
||||
func (s *Service) rollbackAdd(id int64) {
|
||||
_ = s.store.DeleteDomain(id)
|
||||
}
|
||||
|
||||
// Delete removes a domain and everything bound to it — applications and their
|
||||
// SASL/binding rows go via the DB cascade, and the DKIM key and table entries
|
||||
// are removed here (spec 7.2.4, 6.5). The registry row and tables are updated
|
||||
// (so OpenDKIM stops signing for the domain) before the key is deleted.
|
||||
func (s *Service) Delete(id int64) error {
|
||||
d, err := s.store.GetDomain(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.DeleteDomain(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.resync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.odk.RemoveKey(d.Name); err != nil {
|
||||
// The domain is gone from the registry and tables; a leftover key
|
||||
// directory is harmless. Surface it so it is not silently ignored.
|
||||
return fmt.Errorf("domain deleted but key cleanup failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DKIMRecord returns the DNS TXT record to publish for a domain (spec 7.2.10).
|
||||
func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
|
||||
return s.odk.Record(d.Name, d.DKIMSelector)
|
||||
}
|
||||
|
||||
// Resync regenerates the OpenDKIM tables from the registry and reloads OpenDKIM.
|
||||
// It backs the manual reload button (spec 7.2.12) and doubles as a recovery path
|
||||
// if the tables ever drift from the database.
|
||||
func (s *Service) Resync() error {
|
||||
return s.resync()
|
||||
}
|
||||
|
||||
// resync rebuilds KeyTable/SigningTable from the current domain set and reloads.
|
||||
func (s *Service) resync() error {
|
||||
domains, err := s.store.ListDomains()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signing := make([]SigningDomain, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
signing = append(signing, SigningDomain{Name: d.Name, Selector: d.DKIMSelector})
|
||||
}
|
||||
return s.odk.Rebuild(signing)
|
||||
}
|
||||
Reference in New Issue
Block a user