package domain import ( "crypto/x509" "encoding/pem" "fmt" "os" "path" "path/filepath" "sort" "strings" "github.com/mixeme/selfpost/internal/configsafe" "github.com/mixeme/selfpost/internal/supervisor" ) // OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for // (architecture.md § OpenDKIM): per-domain signing keys under keysDir and the // KeyTable/SigningTable that map domains to those keys. After rewriting the // tables it asks OpenDKIM to reload them. type OpenDKIM struct { keysDir string keyTablePath string signingTablePath string // reload sends the running OpenDKIM a reload signal. It is a field so tests // can substitute a no-op; the default drives supervisord (see reloadViaSupervisor). reload func() error } // NewOpenDKIM builds a manager rooted at dir (typically /data/opendkim), the // same layout entrypoint.sh prepares. The default reload path signals OpenDKIM // through supervisord. func NewOpenDKIM(dir string) *OpenDKIM { return &OpenDKIM{ keysDir: filepath.Join(dir, "keys"), keyTablePath: filepath.Join(dir, "KeyTable"), signingTablePath: filepath.Join(dir, "SigningTable"), reload: reloadViaSupervisor, } } // SigningDomain is one row's worth of signing configuration. type SigningDomain struct { Name string Selector string } // keyPath is the private-key path for a domain/selector, matching the KeyTable. func (o *OpenDKIM) keyPath(domainName, selector string) string { return filepath.Join(o.keysDir, domainName, selector+".private") } // EnsureKey makes sure a signing key exists for the domain. An existing key is // reused untouched — critical because overwriting it would silently invalidate // the DKIM record already published in DNS (architecture.md § OpenDKIM). // Returns whether a new key was generated. func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) { if err := assertConfigSafe(domainName, selector); err != nil { return false, err } path := o.keyPath(domainName, selector) if _, err := os.Stat(path); err == nil { return false, nil // reuse existing key } else if !os.IsNotExist(err) { return false, fmt.Errorf("stat dkim key: %w", err) } // setgid on keysDir (entrypoint.sh) makes the per-domain dir inherit the // shared `selfpost` group so OpenDKIM can traverse into it. if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return false, fmt.Errorf("create key dir: %w", err) } key, err := generateDKIMKey() if err != nil { return false, err } if err := writePrivateKeyPEM(path, key); err != nil { return false, err } return true, nil } // RemoveKey deletes a domain's key directory (architecture.md § OpenDKIM). A // missing directory is not an error. func (o *OpenDKIM) RemoveKey(domainName string) error { if err := assertConfigSafe(domainName, "x"); err != nil { return err } if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil { return fmt.Errorf("remove key dir for %s: %w", domainName, err) } return nil } // KeyDomains lists the names that have a key directory. func (o *OpenDKIM) KeyDomains() ([]string, error) { entries, err := os.ReadDir(o.keysDir) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, fmt.Errorf("list key dirs: %w", err) } var out []string for _, e := range entries { if e.IsDir() { out = append(out, e.Name()) } } return out, nil } // ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in // a domain export so the receiving instance signs with the same key and the // DNS TXT record never has to change (architecture.md § Persistence). It // re-marshals the parsed key rather than returning the raw file, so a // malformed on-disk key is caught here. func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) { if err := assertConfigSafe(domainName, selector); err != nil { return nil, err } key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector)) if err != nil { return nil, err } block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} return pem.EncodeToMemory(block), nil } // ImportKey writes an imported DKIM private key to disk for a domain // (architecture.md § Persistence). The PEM is parsed and re-marshalled through // the same writer used for generated keys, so only a well-formed PKCS#1 RSA // key is ever stored. Unlike EnsureKey it overwrites: an import (re-)creates // the domain with exactly this key, which is the whole point of keeping the // published DNS record valid. func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error { if err := assertConfigSafe(domainName, selector); err != nil { return err } block, _ := pem.Decode(pemKey) if block == nil || block.Type != "RSA PRIVATE KEY" { return fmt.Errorf("import dkim key for %s: not a PKCS#1 RSA private key", domainName) } key, err := x509.ParsePKCS1PrivateKey(block.Bytes) if err != nil { return fmt.Errorf("import dkim key for %s: %w", domainName, err) } path := o.keyPath(domainName, selector) if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return fmt.Errorf("create key dir: %w", err) } return writePrivateKeyPEM(path, key) } // Record returns the published DKIM DNS record for a domain, recomputed from the // private key on disk (product.md). func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) { key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector)) if err != nil { return DKIMRecord{}, err } return dkimRecord(selector, domainName, &key.PublicKey) } // SigningTablePath is the SigningTable's location: the names this server // signs for, which the sender check reads (internal/milter/sender.go). func (o *OpenDKIM) SigningTablePath() string { return o.signingTablePath } // Rebuild regenerates KeyTable and SigningTable from the full domain set and // reloads OpenDKIM (architecture.md § OpenDKIM). Full regeneration (rather // than incremental edits) keeps the files a pure function of the registry, so // add and delete share one idempotent path. Both files are written atomically // before the reload signal is sent. func (o *OpenDKIM) Rebuild(domains []SigningDomain) error { keyTable, signingTable, err := renderTables(o.keysDir, domains) if err != nil { return err } if err := writeFileAtomic(o.keyTablePath, keyTable, 0o640); err != nil { return err } if err := writeFileAtomic(o.signingTablePath, signingTable, 0o640); err != nil { return err } return o.reload() } // signs reports whether the tables on disk sign name with the key of // selector: a line for the name in the SigningTable, and in the KeyTable the // line that gives it that selector. Tables that do not exist yet sign nothing. // The selector is part of the question because the panel shows and checks the // record of one key; tables that name another would have OpenDKIM sign with a // key whose record nobody is told to publish. // // OpenDKIM 2.11.0~beta2, opendkim/util.c, dkimf_mkregexp(): "*@name" is // matched as ^.*@name$ (case aside), so it signs name and none of its // subdomains (see Signers.Signs in internal/milter/sender.go). func (o *OpenDKIM) signs(name, selector string) (bool, error) { has := func(path, prefix string) (bool, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { return false, nil } if err != nil { return false, fmt.Errorf("read %s: %w", filepath.Base(path), err) } for _, line := range strings.Split(string(data), "\n") { if strings.HasPrefix(line, prefix) { return true, nil } } return false, nil } signed, err := has(o.signingTablePath, "*@"+name+" ") if err != nil || !signed { return false, err } return has(o.keyTablePath, name+" "+name+":"+selector+":") } // Reload asks OpenDKIM to re-read its tables without regenerating them. It // backs the panel's manual reload button (architecture.md § Panel HTTP // surface). func (o *OpenDKIM) Reload() error { return o.reload() } // SetReloadHook replaces how configuration is applied after a rebuild. Tests // that cannot reach supervisord use this to verify file regeneration alone. func (o *OpenDKIM) SetReloadHook(fn func() error) { if fn != nil { o.reload = fn } } // renderTables builds the KeyTable and SigningTable byte contents for a domain // set, sorted by name so the output is deterministic. Every domain is // re-checked for shell/config-injection safety before being written // (security.md) — validation upstream already guarantees this, but the table // writer refuses to emit anything unsafe as a hard backstop. func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTable []byte, err error) { sorted := append([]SigningDomain(nil), domains...) sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) // OpenDKIM 2.11.0~beta2: KeyTable is a file: table, the key up to the // first blank (opendkim/opendkim-db.c, dkimf_db_open(), DKIMF_DB_TYPE_FILE); // opendkim/opendkim.c, dkimf_add_signrequest(), reads domain, selector and // key from its value, split at ":" (opendkim-db.c, dkimf_db_datasplit()), // a key starting with "/" being a file. SigningTable is refile:, a pattern // up to the first blank and the key name after it (dkimf_db_open(), case // DKIMF_DB_TYPE_REFILE). var kt, st strings.Builder for _, d := range sorted { if err := assertConfigSafe(d.Name, d.Selector); err != nil { return nil, nil, err } keyName := d.Name // one key per domain; the domain name is a fine handle // Absolute key path so OpenDKIM resolves it independently of its CWD. keyFile := path.Join(keysDir, d.Name, d.Selector+".private") // KeyTable: :: fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile) // SigningTable (refile): fmt.Fprintf(&st, "*@%s %s\n", d.Name, keyName) } return []byte(kt.String()), []byte(st.String()), nil } // opendkimForbidden is what may never appear in a KeyTable or SigningTable // value: whitespace and the newline that would end the line, and the colon and // path separators that carry meaning inside a table entry. (OpenDKIM // 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_datasplit(), splits a // KeyTable value at ":"; the domain and selector are also the key's path.) const opendkimForbidden = " \t\r\n:/\\" // assertConfigSafe rejects any domain/selector value that could break out of a // single table line. Domains are already whitelisted to [a-z0-9.-] and selectors // to a similar set before they reach here (security.md); this is defence in depth // against a validation gap ever letting whitespace, a newline or a field // separator through into a config file (security.md). func assertConfigSafe(domainName, selector string) error { for _, f := range []struct{ what, value string }{ {"domain", domainName}, {"selector", selector}, } { if err := configsafe.Token(f.what, f.value, opendkimForbidden); err != nil { return fmt.Errorf("opendkim: %w", err) } } return nil } // reloadViaSupervisor asks supervisord (PID 1, running as root) to send the // OpenDKIM process SIGUSR1, which makes it re-read KeyTable/SigningTable // (opendkim's documented reload signal). The panel runs unprivileged and cannot // signal another user's process directly, so it goes through the supervisor // control socket, reachable via the shared `selfpost` group (security.md). // // Arguments are fixed literals — no user input is interpolated into the command, // and it never goes through a shell (security.md). // // OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, dkimf_reloader(): SIGUSR1 sets // reload when a configuration file was given (-x in build/supervisord.conf); // dkimf_config_reload(), from mlfi_negotiate() at the next milter connection, // loads the configuration and opens the tables again (opendkim.8, OPERATION). // supervisor 4.2.5, supervisor/rpcinterface.py, signalProcess(), and // process.py, signal(): kill() of the program's own pid - opendkim itself, run // with -f (not forked into the background) and exec'd with no shell. func reloadViaSupervisor() error { return supervisor.Signal("USR1", "opendkim") }