Files
selfpost/internal/domain/opendkim.go
T
mix bf477f4e6a Phase 3: sending domains + per-domain OpenDKIM signing
Add/list/delete of sending domains with per-domain DKIM keys and the
OpenDKIM tables that drive signing (spec 6, 7.2.2-4, 7.2.10).

internal/domain:
- Pure-Go RSA-2048 keygen; PKCS#1 PEM written atomically at 0640; the
  published DNS TXT record is derived from the key on disk (single source
  of truth) rather than persisted. No os/exec for key generation.
- KeyTable/SigningTable fully regenerated from the registry on every
  add/delete (idempotent), written atomically; SigningTable via refile:
  with *@domain, KeyTable with absolute key paths. Table writer refuses
  any unsafe character as a backstop (spec 7.6.4).
- Reload without root: the unprivileged panel signals OpenDKIM through
  supervisord (`supervisorctl signal USR1 opendkim`, fixed args, no
  shell, no user input — spec 7.6.3). An existing key is reused, never
  overwritten, so re-adding a domain keeps its published DNS valid.
- Service orchestrates registry -> key -> table rebuild -> reload, with
  rollback of the row if a downstream step fails; delete cascades apps
  via the DB FK and removes the key + table entries.

Infra:
- Shared `selfpost` group bridges panel (writes keys) and opendkim
  (reads them); /data/opendkim is setgid so panel-created files inherit
  the group, keys are 0640, RequireSafeKeys is disabled by design.
- opendkim.conf moves from verify-only (Mode v) to signing (Mode s).
- entrypoint.sh normalises the DKIM tree on every start (ownership,
  setgid, perms, empty tables before opendkim starts) — self-healing
  after a restore.
- supervisord control socket opened to the `selfpost` group so the panel
  can request the reload.

web/store:
- Strict domain-name validation (whitelist [a-z0-9.-], DNS shape, >=2
  labels), lower-case normalisation (spec 7.6.2).
- Domain queries with application counts; delete relies on ON DELETE
  CASCADE. Dashboard lists domains + add form; domain page shows the
  DKIM record; a dedicated confirm page warns about the app cascade
  before deletion (spec 7.2.4); manual reload button (spec 7.2.12,
  OpenDKIM side; Postfix reload lands in Phase 5).
- Authenticated routes moved to a sub-mux using Go 1.22 method/wildcard
  patterns.

Tests: validateDomain, DKIM keygen/record roundtrip, table rendering +
injection-safety, key reuse, store cascade. Verified on the dev server:
gofmt/vet/test green, image builds, container e2e (add/delete a domain,
DKIM record shown, OpenDKIM reads panel keys and reloads, keys and
tables persist across a restart).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:20:35 +03:00

185 lines
6.8 KiB
Go

package domain
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
// (spec 6): per-domain signing keys under keysDir and the KeyTable/SigningTable
// that map domains to those keys. After rewriting the tables it asks OpenDKIM to
// reload them.
type OpenDKIM struct {
keysDir string
keyTablePath string
signingTablePath string
// reload sends the running OpenDKIM a reload signal. It is a field so tests
// can substitute a no-op; the default drives supervisord (see reloadViaSupervisor).
reload func() error
}
// NewOpenDKIM builds a manager rooted at dir (typically /data/opendkim), the
// same layout entrypoint.sh prepares. The default reload path signals OpenDKIM
// through supervisord.
func NewOpenDKIM(dir string) *OpenDKIM {
return &OpenDKIM{
keysDir: filepath.Join(dir, "keys"),
keyTablePath: filepath.Join(dir, "KeyTable"),
signingTablePath: filepath.Join(dir, "SigningTable"),
reload: reloadViaSupervisor,
}
}
// SigningDomain is one row's worth of signing configuration.
type SigningDomain struct {
Name string
Selector string
}
// keyPath is the private-key path for a domain/selector, matching the KeyTable.
func (o *OpenDKIM) keyPath(domainName, selector string) string {
return filepath.Join(o.keysDir, domainName, selector+".private")
}
// EnsureKey makes sure a signing key exists for the domain. An existing key is
// reused untouched — critical because overwriting it would silently invalidate
// the DKIM record already published in DNS (spec 6.1). Returns whether a new key
// was generated.
func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return false, err
}
path := o.keyPath(domainName, selector)
if _, err := os.Stat(path); err == nil {
return false, nil // reuse existing key
} else if !os.IsNotExist(err) {
return false, fmt.Errorf("stat dkim key: %w", err)
}
// setgid on keysDir (entrypoint.sh) makes the per-domain dir inherit the
// shared `selfpost` group so OpenDKIM can traverse into it.
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return false, fmt.Errorf("create key dir: %w", err)
}
key, err := generateDKIMKey()
if err != nil {
return false, err
}
if err := writePrivateKeyPEM(path, key); err != nil {
return false, err
}
return true, nil
}
// RemoveKey deletes a domain's key directory (spec 6.5). A missing directory is
// not an error.
func (o *OpenDKIM) RemoveKey(domainName string) error {
if err := assertConfigSafe(domainName, "x"); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil {
return fmt.Errorf("remove key dir for %s: %w", domainName, err)
}
return nil
}
// Record returns the published DKIM DNS record for a domain, recomputed from the
// private key on disk (spec 7.2.10).
func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) {
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return DKIMRecord{}, err
}
return dkimRecord(selector, domainName, &key.PublicKey)
}
// Rebuild regenerates KeyTable and SigningTable from the full domain set and
// reloads OpenDKIM (spec 6.2). Full regeneration (rather than incremental
// edits) keeps the files a pure function of the registry, so add and delete
// share one idempotent path. Both files are written atomically before the
// reload signal is sent.
func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
keyTable, signingTable, err := renderTables(o.keysDir, domains)
if err != nil {
return err
}
if err := writeFileAtomic(o.keyTablePath, keyTable, 0o640); err != nil {
return err
}
if err := writeFileAtomic(o.signingTablePath, signingTable, 0o640); err != nil {
return err
}
return o.reload()
}
// Reload asks OpenDKIM to re-read its tables without regenerating them. It backs
// the panel's manual reload button (spec 7.2.12).
func (o *OpenDKIM) Reload() error {
return o.reload()
}
// renderTables builds the KeyTable and SigningTable byte contents for a domain
// set, sorted by name so the output is deterministic. Every domain is
// re-checked for shell/config-injection safety before being written (spec
// 7.6.4) — validation upstream already guarantees this, but the table writer
// refuses to emit anything unsafe as a hard backstop.
func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTable []byte, err error) {
sorted := append([]SigningDomain(nil), domains...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
var kt, st strings.Builder
for _, d := range sorted {
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
return nil, nil, err
}
keyName := d.Name // one key per domain; the domain name is a fine handle
// Absolute key path so OpenDKIM resolves it independently of its CWD.
keyFile := filepath.Join(keysDir, d.Name, d.Selector+".private")
// KeyTable: <key-name> <domain>:<selector>:<key-path>
fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile)
// SigningTable (refile): <address-pattern> <key-name>
fmt.Fprintf(&st, "*@%s %s\n", d.Name, keyName)
}
return []byte(kt.String()), []byte(st.String()), nil
}
// assertConfigSafe rejects any domain/selector value that could break out of a
// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors
// to a similar set before they reach here (spec 7.6.2); this is defence in depth
// against a validation gap ever letting whitespace, a newline or a field
// separator through into a config file (spec 7.6.4).
func assertConfigSafe(domainName, selector string) error {
for _, v := range []string{domainName, selector} {
if v == "" {
return fmt.Errorf("opendkim: empty domain or selector")
}
if strings.ContainsAny(v, " \t\r\n:/\\") {
return fmt.Errorf("opendkim: unsafe character in %q", v)
}
}
return nil
}
// reloadViaSupervisor asks supervisord (PID 1, running as root) to send the
// OpenDKIM process SIGUSR1, which makes it re-read KeyTable/SigningTable
// (opendkim's documented reload signal). The panel runs unprivileged and cannot
// signal another user's process directly, so it goes through the supervisor
// control socket, reachable via the shared `selfpost` group (spec 7.6.3, 7.6.8).
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (spec 7.6.3).
func reloadViaSupervisor() error {
cmd := exec.Command("supervisorctl",
"-c", "/etc/supervisor/supervisord.conf",
"signal", "USR1", "opendkim")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("reload opendkim via supervisor: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}