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 // (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 } // 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) } // 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() } // 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 }) 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: :: 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 } // 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 _, 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 (security.md). // // Arguments are fixed literals — no user input is interpolated into the command, // and it never goes through a shell (security.md). 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 }