test / test (push) Waiting to run
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.
Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.
Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.
Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.
KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.
e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
8.4 KiB
Go
240 lines
8.4 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
)
|
|
|
|
// FormatDomainExport identifies a single-domain export file (architecture.md §
|
|
// Persistence).
|
|
const FormatDomainExport = "selfpost-domain-export"
|
|
|
|
// DomainExport is the serialisable form of one sending domain, for moving it
|
|
// between two independently running SelfPost instances (architecture.md §
|
|
// Persistence). 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
|
|
DMARCRua string `json:"dmarc_rua,omitempty"` // the domain's report address; absent = no reports
|
|
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
|
|
Applications []AppExport `json:"applications"`
|
|
}
|
|
|
|
// RateLimitExport is the transferable level-2 limit for a domain or application.
|
|
type RateLimitExport struct {
|
|
Mode string `json:"mode,omitempty"`
|
|
MaxMessages int `json:"max_messages,omitempty"`
|
|
WindowSeconds int `json:"window_seconds,omitempty"`
|
|
AutoMultiplier float64 `json:"auto_multiplier,omitempty"`
|
|
AllowedIPs []string `json:"allowed_ips,omitempty"`
|
|
}
|
|
|
|
// 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"`
|
|
AuthIPRestrict bool `json:"auth_ip_restrict,omitempty"`
|
|
AuthAllowedIPs []string `json:"auth_allowed_ips,omitempty"`
|
|
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
|
|
}
|
|
|
|
// Export builds the transferable representation of a domain: its DKIM key, its
|
|
// selector and every application with its address mode and working password
|
|
// (architecture.md § Persistence). 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.keys.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),
|
|
DMARCRua: d.DMARCRua,
|
|
Applications: make([]AppExport, 0, len(apps)),
|
|
}
|
|
rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id)
|
|
if err != nil {
|
|
return DomainExport{}, fmt.Errorf("export rate limit for %s: %w", d.Name, err)
|
|
}
|
|
if ok {
|
|
exp.RateLimit = exportRateLimit(rl)
|
|
}
|
|
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)
|
|
}
|
|
appExp := AppExport{
|
|
Login: a.Login,
|
|
AddressMode: a.AddressMode,
|
|
Addresses: a.Addresses,
|
|
Password: password,
|
|
AuthIPRestrict: a.AuthIPRestrict,
|
|
AuthAllowedIPs: a.AuthAllowedIPs,
|
|
}
|
|
appRL, ok, err := s.store.GetRateLimit(store.RateLimitScopeApp, a.ID)
|
|
if err != nil {
|
|
return DomainExport{}, fmt.Errorf("export rate limit for %s: %w", a.Login, err)
|
|
}
|
|
if ok {
|
|
appExp.RateLimit = exportRateLimit(appRL)
|
|
}
|
|
exp.Applications = append(exp.Applications, appExp)
|
|
}
|
|
return exp, nil
|
|
}
|
|
|
|
// Import re-creates a domain from an export file on this instance
|
|
// (architecture.md § Persistence): it stores the imported DKIM key (so the
|
|
// published DNS record needs no change), registers the domain and rebuilds the
|
|
// selector table, 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
|
|
// (security.md); 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.keys.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
|
|
}
|
|
|
|
if exp.DMARCRua != "" {
|
|
if err := s.store.SetDomainDMARCAddress(d.ID, exp.DMARCRua); err != nil {
|
|
s.importRollback(d.ID)
|
|
return store.Domain{}, err
|
|
}
|
|
d.DMARCRua = exp.DMARCRua
|
|
}
|
|
if exp.RateLimit != nil {
|
|
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); 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)
|
|
}
|
|
restrict, ips := a.AuthIPRestrict, a.AuthAllowedIPs
|
|
// Legacy exports stored client IPs on the rate-limit row as trusted IPs.
|
|
if a.RateLimit != nil && len(a.RateLimit.AllowedIPs) > 0 {
|
|
restrict = true
|
|
ips = a.RateLimit.AllowedIPs
|
|
}
|
|
needAuthIPs := restrict || len(ips) > 0
|
|
needRateLimit := a.RateLimit != nil
|
|
if needAuthIPs || needRateLimit {
|
|
app, err := s.store.GetApplicationByLogin(a.Login)
|
|
if err != nil {
|
|
s.importRollback(d.ID)
|
|
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
|
|
}
|
|
if needAuthIPs {
|
|
if err := s.store.UpdateApplicationAuthIPs(app.ID, restrict, ips); err != nil {
|
|
s.importRollback(d.ID)
|
|
return store.Domain{}, fmt.Errorf("import auth IPs for %q: %w", a.Login, err)
|
|
}
|
|
}
|
|
if needRateLimit {
|
|
if err := s.importRateLimit(store.RateLimitScopeApp, app.ID, *a.RateLimit); err != nil {
|
|
s.importRollback(d.ID)
|
|
return store.Domain{}, fmt.Errorf("import rate limit for %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)
|
|
}
|
|
|
|
func exportRateLimit(rl store.RateLimit) *RateLimitExport {
|
|
mode := rl.Mode
|
|
if mode == "" {
|
|
mode = store.RateLimitModeManual
|
|
}
|
|
exp := &RateLimitExport{
|
|
Mode: mode,
|
|
MaxMessages: rl.MaxMessages,
|
|
WindowSeconds: rl.WindowSeconds,
|
|
AutoMultiplier: rl.AutoMultiplier,
|
|
}
|
|
return exp
|
|
}
|
|
|
|
func (s *Service) importRateLimit(scope string, refID int64, exp RateLimitExport) error {
|
|
mode := exp.Mode
|
|
if mode == "" {
|
|
mode = store.RateLimitModeManual
|
|
}
|
|
rl := store.RateLimit{
|
|
Scope: scope,
|
|
RefID: refID,
|
|
Mode: mode,
|
|
MaxMessages: exp.MaxMessages,
|
|
WindowSeconds: exp.WindowSeconds,
|
|
AutoMultiplier: exp.AutoMultiplier,
|
|
}
|
|
if mode == store.RateLimitModeManual && rl.MaxMessages <= 0 && rl.WindowSeconds <= 0 {
|
|
return nil
|
|
}
|
|
return s.store.SetRateLimit(rl)
|
|
}
|