release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image

Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`).
Added:
  - Panel (Go, single static binary) with SQLite persistence, one-time
  - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and
  - Application (sender identity) management: SASL credentials via `sasldb2`,
  - Full Postfix relay config generated from env at container start: SMTPS 465,
  - Journal milter (pure Go, `go-milter`) recording every send to `send_log`;
  … (6 more in CHANGELOG)

Full detail: CHANGELOG.md (0.1.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
commit 8ce050ef7f
157 changed files with 17631 additions and 0 deletions
+127
View File
@@ -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
}
+86
View File
@@ -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))
}
}
+226
View File
@@ -0,0 +1,226 @@
package domain
import (
"crypto/x509"
"encoding/pem"
"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
}
// 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 (spec 7.5.B). 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 (spec
// 7.5.B). 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 (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
}
+156
View File
@@ -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)
}
}
+180
View File
@@ -0,0 +1,180 @@
package domain
import (
"fmt"
"codeberg.org/mix/selfpost/internal/store"
)
// Applications is the slice of the application service the domain service needs
// to keep the SASL database and sender map consistent when a domain (and its
// applications, via cascade) is deleted. *app.Service satisfies it; it is an
// interface here to avoid a package import cycle and to keep domain deletion
// testable in isolation.
type Applications interface {
// PurgeDomainSASL removes the SASL accounts of the domain's applications.
// It must run before the registry cascade so the logins are still known.
PurgeDomainSASL(domainID int64) error
// Resync rebuilds smtpd_sender_login_maps from the remaining applications
// and reloads Postfix.
Resync() error
// Secret returns an application's stored password, for a domain export
// (spec 7.5.B).
Secret(login string) (string, error)
// ImportApplication re-creates an application (registry row + SASL account)
// from a domain-export file, without rebuilding the sender map (spec 7.5.B).
ImportApplication(domainID int64, login, mode string, addresses []string, password string) error
}
// 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 (spec 6, 7.2.2-4, 7.2.10).
type Service struct {
store *store.Store
odk *OpenDKIM
apps Applications
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. 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}
}
// 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 (spec 7.2.4, 6.5). 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.
func (s *Service) Delete(id int64) error {
d, err := s.store.GetDomain(id)
if err != nil {
return err
}
if err := s.apps.PurgeDomainSASL(id); err != nil {
return fmt.Errorf("clear SASL accounts for %s: %w", d.Name, err)
}
// Drop the domain's own level-2 limit and those of its applications while the
// application rows still exist (the cleanup query joins them). rate_limits has
// no cascade of its own (ref_id is a plain integer, spec 7.4/9).
if err := s.store.DeleteRateLimitsForDomain(id); err != nil {
return fmt.Errorf("clear rate limits for %s: %w", d.Name, err)
}
if err := s.store.DeleteDomain(id); err != nil {
return err
}
if err := s.resync(); err != nil {
return err
}
if err := s.apps.Resync(); err != nil {
return fmt.Errorf("rebuild sender map after deleting %s: %w", d.Name, 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)
}
// RateLimit returns the domain-level differentiated rate limit (spec 7.4), and
// whether one is configured, for the domain's edit form.
func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeDomain, domainID)
}
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
// IPs and numbers (spec 7.6.2); the milter reads the row live, so no reload is
// needed.
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeDomain,
RefID: domainID,
AllowedIPs: ips,
MaxMessages: maxMessages,
WindowSeconds: windowSeconds,
})
}
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
// only (spec 7.4).
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 (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)
}
+131
View File
@@ -0,0 +1,131 @@
package domain
import (
"fmt"
"codeberg.org/mix/selfpost/internal/buildinfo"
"codeberg.org/mix/selfpost/internal/store"
)
// FormatDomainExport identifies a single-domain export file (spec 7.5.B).
const FormatDomainExport = "selfpost-domain-export"
// DomainExport is the serialisable form of one sending domain, for moving it
// between two independently running SelfPost instances (spec 7.5.B). It carries
// the DKIM private key (so the published DNS record stays valid) and each
// application's working password (so credentials transfer without regeneration).
// The file is therefore as sensitive as a full backup and must be handled as a
// secret.
type DomainExport struct {
Format string `json:"format"`
Version string `json:"version"`
Domain string `json:"domain"`
DKIMSelector string `json:"dkim_selector"`
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
Applications []AppExport `json:"applications"`
}
// AppExport is one application within a DomainExport.
type AppExport struct {
Login string `json:"login"`
AddressMode string `json:"address_mode"`
Addresses []string `json:"addresses,omitempty"` // list mode only
Password string `json:"password"`
}
// Export builds the transferable representation of a domain: its DKIM key, its
// selector and every application with its address mode and working password
// (spec 7.5.B). The returned struct is marshalled to JSON by the caller and
// offered as a secret download.
func (s *Service) Export(id int64) (DomainExport, error) {
d, err := s.store.GetDomain(id)
if err != nil {
return DomainExport{}, err
}
pem, err := s.odk.ExportKey(d.Name, d.DKIMSelector)
if err != nil {
return DomainExport{}, fmt.Errorf("export DKIM key for %s: %w", d.Name, err)
}
apps, err := s.store.ListApplicationsByDomain(id)
if err != nil {
return DomainExport{}, err
}
exp := DomainExport{
Format: FormatDomainExport,
Version: buildinfo.Version,
Domain: d.Name,
DKIMSelector: d.DKIMSelector,
DKIMPrivateKey: string(pem),
Applications: make([]AppExport, 0, len(apps)),
}
for _, a := range apps {
password, err := s.apps.Secret(a.Login)
if err != nil {
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
}
exp.Applications = append(exp.Applications, AppExport{
Login: a.Login,
AddressMode: a.AddressMode,
Addresses: a.Addresses,
Password: password,
})
}
return exp, nil
}
// Import re-creates a domain from an export file on this instance (spec 7.5.B):
// 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 and rebuilds the Postfix sender map.
//
// exp.Domain must already be normalised and validated by the caller (spec
// 7.6.2); the selector is checked for config-injection safety here. A domain or
// login that already exists is rejected (store.ErrDomainExists /
// store.ErrLoginExists) rather than merged. If any step fails, everything the
// import created is rolled back, so a partial import never leaves the instance
// in an inconsistent state.
func (s *Service) Import(exp DomainExport) (store.Domain, error) {
if exp.Format != FormatDomainExport {
return store.Domain{}, fmt.Errorf("not a SelfPost domain export (format %q)", exp.Format)
}
if err := assertConfigSafe(exp.Domain, exp.DKIMSelector); err != nil {
return store.Domain{}, err
}
// Registry row first, so its UNIQUE constraint is the sole arbiter of a
// duplicate domain before we touch the filesystem.
d, err := s.store.AddDomain(exp.Domain, exp.DKIMSelector)
if err != nil {
return store.Domain{}, err // ErrDomainExists surfaces to the caller
}
if err := s.odk.ImportKey(d.Name, d.DKIMSelector, []byte(exp.DKIMPrivateKey)); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
if err := s.resync(); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
for _, a := range exp.Applications {
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
}
}
if err := s.apps.Resync(); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
return d, nil
}
// importRollback best-effort tears down a partially imported domain by running
// the normal deletion path, which clears the SASL accounts of any applications
// already created, removes the registry rows (cascade), rebuilds both maps and
// removes the DKIM key. Any error here is subordinate to the original failure
// the caller returns.
func (s *Service) importRollback(id int64) {
_ = s.Delete(id)
}
+190
View File
@@ -0,0 +1,190 @@
package domain
import (
"errors"
"fmt"
"testing"
"codeberg.org/mix/selfpost/internal/store"
)
// fakeApps stands in for *app.Service in the domain-transfer tests: it records
// the import calls and hands back canned SASL secrets.
type fakeApps struct {
secrets map[string]string
imported []importedApp
importErr error
}
type importedApp struct {
domainID int64
login string
mode string
addresses []string
password string
}
func (f *fakeApps) PurgeDomainSASL(int64) error { return nil }
func (f *fakeApps) Resync() error { return nil }
func (f *fakeApps) Secret(login string) (string, error) {
pw, ok := f.secrets[login]
if !ok {
return "", fmt.Errorf("no secret for %q", login)
}
return pw, nil
}
func (f *fakeApps) ImportApplication(domainID int64, login, mode string, addresses []string, password string) error {
if f.importErr != nil {
return f.importErr
}
f.imported = append(f.imported, importedApp{domainID, login, mode, addresses, password})
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) {
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
}
func TestExportImportRoundTrip(t *testing.T) {
// Source instance: a domain with two applications and their secrets.
srcApps := &fakeApps{secrets: map[string]string{"mailer": "pw-mailer", "alerts": "pw-alerts"}}
src, srcOdk := newTestService(t, srcApps)
d, err := src.Add("example.com")
if err != nil {
t.Fatalf("add domain: %v", err)
}
if _, err := src.store.AddApplication(d.ID, "mailer", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add mailer: %v", err)
}
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
t.Fatalf("add alerts: %v", err)
}
exp, err := src.Export(d.ID)
if err != nil {
t.Fatalf("Export: %v", err)
}
if exp.Format != FormatDomainExport || exp.Domain != "example.com" || exp.DKIMSelector != "selfpost" {
t.Fatalf("export header = %+v", exp)
}
if len(exp.Applications) != 2 {
t.Fatalf("exported %d apps, want 2", len(exp.Applications))
}
srcKey, err := srcOdk.ExportKey("example.com", "selfpost")
if err != nil {
t.Fatalf("read source key: %v", err)
}
if exp.DKIMPrivateKey != string(srcKey) {
t.Error("export DKIM key does not match the on-disk key")
}
// Target instance: import the file.
dstApps := &fakeApps{}
dst, dstOdk := newTestService(t, dstApps)
nd, err := dst.Import(exp)
if err != nil {
t.Fatalf("Import: %v", err)
}
// Domain row landed with the exported selector.
got, err := dst.Get(nd.ID)
if err != nil {
t.Fatalf("get imported domain: %v", err)
}
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
t.Errorf("imported domain = %+v", got)
}
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
if err != nil {
t.Fatalf("read imported key: %v", err)
}
if string(dstKey) != string(srcKey) {
t.Error("imported DKIM key differs from the source key")
}
// Applications were re-created with their working passwords.
if len(dstApps.imported) != 2 {
t.Fatalf("imported %d apps, want 2", len(dstApps.imported))
}
byLogin := map[string]importedApp{}
for _, a := range dstApps.imported {
byLogin[a.login] = a
}
if byLogin["mailer"].password != "pw-mailer" || byLogin["alerts"].password != "pw-alerts" {
t.Errorf("imported passwords = %+v", dstApps.imported)
}
if byLogin["alerts"].mode != store.AddressModeList {
t.Errorf("alerts mode = %q", byLogin["alerts"].mode)
}
}
func TestImportRejectsWrongFormat(t *testing.T) {
dst, _ := newTestService(t, &fakeApps{})
if _, err := dst.Import(DomainExport{Format: "nope", Domain: "example.com"}); err == nil {
t.Error("Import accepted a non-export file")
}
}
func TestImportRejectsDuplicateDomain(t *testing.T) {
dst, _ := newTestService(t, &fakeApps{})
if _, err := dst.Add("example.com"); err != nil {
t.Fatalf("seed domain: %v", err)
}
// A minimal but well-formed export of the same domain.
src, srcOdk := newTestService(t, &fakeApps{})
d, _ := src.Add("example.com")
key, _ := srcOdk.ExportKey("example.com", "selfpost")
exp := DomainExport{
Format: FormatDomainExport, Domain: "example.com", DKIMSelector: "selfpost",
DKIMPrivateKey: string(key),
}
_ = d
if _, err := dst.Import(exp); !errors.Is(err, store.ErrDomainExists) {
t.Errorf("Import duplicate = %v, want ErrDomainExists", err)
}
}
func TestImportRollsBackOnAppFailure(t *testing.T) {
// Build a valid export from a source instance.
src, _ := newTestService(t, &fakeApps{secrets: map[string]string{"mailer": "pw"}})
d, _ := src.Add("example.com")
if _, err := src.store.AddApplication(d.ID, "mailer", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add app: %v", err)
}
exp, err := src.Export(d.ID)
if err != nil {
t.Fatalf("Export: %v", err)
}
// Target rejects the application import; the whole domain must roll back.
dstApps := &fakeApps{importErr: errors.New("boom")}
dst, dstOdk := newTestService(t, dstApps)
if _, err := dst.Import(exp); err == nil {
t.Fatal("Import succeeded despite an application failure")
}
// Domain row removed.
domains, err := dst.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(domains) != 0 {
t.Errorf("expected rollback to remove the domain, got %+v", domains)
}
// DKIM key removed.
if _, err := dstOdk.ExportKey("example.com", "selfpost"); err == nil {
t.Error("expected rollback to remove the imported DKIM key")
}
}