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:
@@ -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,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
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user