f88d8dabcb
Full server backup (spec 7.5.A): internal/backup produces a tar.gz of all of /data — a consistent SQLite snapshot via VACUUM INTO, DKIM keys, sasldb2 and a version manifest; TLS certs (tls/) and the Postfix queue are excluded. Two equal paths: the panel button (POST /backup, no-store) and the selfpost-backup CLI via docker exec (spec 11.6). CheckRestore runs before store.Open: a manifest version mismatch refuses to boot with the image tag to use; a match consumes the manifest so it only guards the first post-restore boot. Restore is not a separate branch — Postfix/OpenDKIM regenerate from the restored SQLite as on any start. Domain export/import (spec 7.5.B): DomainExport carries the DKIM private key and each application's working password. SASL secrets are read from sasldb2 via db_dump (the userPassword property is plaintext) and, on import, re-keyed under the local realm with saslpasswd2 — so credentials keep working on an instance with a different hostname, with no DKIM DNS change. Import validates and rolls back atomically on any failure. db-util (db_dump) is now an explicit image dep. Verified on the server (selfpost:p9): gofmt/vet/test green; container e2e for cross-realm domain export/import (SMTP AUTH 235 under the new realm), CLI and panel backups, same-version restore, and version-mismatch refusal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
8.5 KiB
Go
227 lines
8.5 KiB
Go
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
|
|
}
|