test / test (push) Canceled after 0s
Neither review (read-only agents, Opus and Fable, over 972a9f8..400aa76) has a Critical finding. Both found the same serious one, and more: - The notification about refused mail was built on what a rejected row says of itself. Its domain is the one in MAIL FROM, which the client typed and Postfix has not yet held against the login when the milter refuses: an application over its limit could have had the panel write a message per made-up domain, to another tenant's users among others. And the same status is written when an application is refused for its client address, which is no limit. Refusals are now counted by the domain the login is registered under, only for logins that exist and only while a limit is set on the login or its domain; one subject per sending domain. They are read through a partial index of their own (migration 0005): the query walked the whole send log every minute. - Account and user e-mails were checked with the DMARC report validator, which refuses Gmail and the like - the owner's own kind of address. A new validate.AccountEmail accepts them and refuses a local part beginning "-". - "Said" now means sent: what arises while nobody can be told, or while Postfix does not take the message, is told at a later check. Its ending with nobody left to tell is simply over. - Two DNS checks must be 45 minutes apart as well as two: the last check before a restart and the first after it were minutes apart. - The notifier's own mail waiting in the queue, and its bounce coming back to postmaster@ this server, are not the queue's subject. A listing cut at its size limit or with a line too long to read is no listing; postqueue -j gets 30 seconds. - The tables must name the selector the panel shows, not only have the line. - An a/mx lookup that did not answer inside the SPF check made the record "not authorising"; it is "cannot tell" now, for the pages too. - A ceiling of 60 messages an hour; a pass stops between domains when the process is asked to stop; sendmail gets a WaitDelay; days are rounded as a person counts; a failed read of the switch shows it on, not off. Recorded as accepted in security.md, with a roadmap candidate (from-header-binding): an application can have a message signed as the server's own name - or, as always, as another registered domain - by writing it into the From: header, which nothing holds against the login. Left as found: a recipient whose copy failed while another's went out is not tried again; a certificate that lives under 14 days is reported at each renewal. On the test server, for this tree: go vet clean, go test ./... 23 packages ok, make e2e ok (326 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
11 KiB
Go
284 lines
11 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
)
|
|
|
|
// Applications is the slice of the application service the domain service needs
|
|
// to keep the SASL database and sender map consistent when a domain (and its
|
|
// applications, via cascade) is deleted. *app.Service satisfies it; it is an
|
|
// interface here to avoid a package import cycle and to keep domain deletion
|
|
// testable in isolation.
|
|
type Applications interface {
|
|
// PurgeDomainSASL removes the SASL accounts of the domain's applications.
|
|
// It must run before the registry cascade so the logins are still known.
|
|
PurgeDomainSASL(domainID int64) error
|
|
// Resync rebuilds smtpd_sender_login_maps from the remaining applications
|
|
// and reloads Postfix.
|
|
Resync() error
|
|
// Secret returns an application's stored password, for a domain export
|
|
// (architecture.md § Persistence).
|
|
Secret(login string) (string, error)
|
|
// ImportApplication re-creates an application (registry row + SASL account)
|
|
// from a domain-export file, without rebuilding the sender map
|
|
// (architecture.md § Persistence).
|
|
ImportApplication(domainID int64, login, mode string, addresses []string, password string) error
|
|
}
|
|
|
|
// Service coordinates the places a sending domain lives: the SQLite registry,
|
|
// the on-disk DKIM keys and OpenDKIM's tables, plus — on deletion — the SASL
|
|
// database and Postfix sender map its applications touch. Callers (the web
|
|
// handlers) validate user input first; Service keeps the stores in agreement
|
|
// and drives the OpenDKIM/Postfix reloads (architecture.md § OpenDKIM,
|
|
// product.md).
|
|
type Service struct {
|
|
store *store.Store
|
|
odk *OpenDKIM
|
|
apps Applications
|
|
selector string
|
|
// serverName is the server's own hostname, which the panel's notifications
|
|
// are sent from and signed as (SetServerName). Empty: nothing is signed
|
|
// for it.
|
|
serverName string
|
|
}
|
|
|
|
// SetServerName gives the service the server's own hostname. The panel sends
|
|
// its notifications from postmaster@ that name, and for a receiver to believe
|
|
// them the name is signed like a sending domain: it gets a DKIM key and a line
|
|
// in OpenDKIM's tables, without being a row of the registry - no application
|
|
// can send as it. The name comes from SELFPOST_HOSTNAME, which the entrypoint
|
|
// has validated; it is not user input.
|
|
func (s *Service) SetServerName(hostname string) {
|
|
s.serverName = hostname
|
|
}
|
|
|
|
// ServerMail is how the server's own name is signed.
|
|
type ServerMail struct {
|
|
Name string
|
|
Selector string
|
|
Record DKIMRecord
|
|
}
|
|
|
|
// ServerMail returns the name notifications are sent as, its DKIM selector and
|
|
// the record to publish for it, creating the key the first time. When the
|
|
// hostname is itself a sending domain, it is that domain's key and selector:
|
|
// one name has one key.
|
|
func (s *Service) ServerMail() (ServerMail, error) {
|
|
if s.serverName == "" {
|
|
return ServerMail{}, fmt.Errorf("server mail: no hostname")
|
|
}
|
|
selector, err := s.serverSelector()
|
|
if err != nil {
|
|
return ServerMail{}, err
|
|
}
|
|
if _, err := s.odk.EnsureKey(s.serverName, selector); err != nil {
|
|
return ServerMail{}, err
|
|
}
|
|
rec, err := s.odk.Record(s.serverName, selector)
|
|
if err != nil {
|
|
return ServerMail{}, err
|
|
}
|
|
return ServerMail{Name: s.serverName, Selector: selector, Record: rec}, nil
|
|
}
|
|
|
|
// EnsureServerSigned makes sure OpenDKIM's tables have the line for the
|
|
// server's own name, rebuilding them if they do not. The tables are otherwise
|
|
// rebuilt only when a domain is added or deleted, so an installation that was
|
|
// upgraded to a version with notifications would go on sending them unsigned
|
|
// until then, and one whose DKIM_SELECTOR_DEFAULT was changed would go on
|
|
// signing with the key of the old selector while the panel shows the record
|
|
// of the new. It reports whether it had to rebuild.
|
|
func (s *Service) EnsureServerSigned() (bool, error) {
|
|
if s.serverName == "" {
|
|
return false, nil
|
|
}
|
|
selector, err := s.serverSelector()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
signed, err := s.odk.signs(s.serverName, selector)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if signed {
|
|
return false, nil
|
|
}
|
|
return true, s.resync()
|
|
}
|
|
|
|
// serverSelector is the selector the server's own name is signed with: that of
|
|
// the sending domain of the same name if there is one, the default otherwise.
|
|
func (s *Service) serverSelector() (string, error) {
|
|
domains, err := s.store.ListDomains()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, d := range domains {
|
|
if d.Name == s.serverName {
|
|
return d.DKIMSelector, nil
|
|
}
|
|
}
|
|
return s.selector, nil
|
|
}
|
|
|
|
// NewService builds the domain service. selectorDefault is the DKIM selector
|
|
// assigned to new domains (guide § Environment variables:
|
|
// DKIM_SELECTOR_DEFAULT); it is operator-configured, not user input. apps is
|
|
// used only on deletion, to clear the SASL accounts and sender-map bindings of
|
|
// the domain's applications.
|
|
func NewService(st *store.Store, odk *OpenDKIM, apps Applications, selectorDefault string) *Service {
|
|
return &Service{store: st, odk: odk, apps: apps, selector: selectorDefault}
|
|
}
|
|
|
|
// List returns all domains with application counts (product.md).
|
|
func (s *Service) List() ([]store.Domain, error) {
|
|
return s.store.ListDomains()
|
|
}
|
|
|
|
// Get returns one domain by id (store.ErrDomainNotFound if absent).
|
|
func (s *Service) Get(id int64) (store.Domain, error) {
|
|
return s.store.GetDomain(id)
|
|
}
|
|
|
|
// Add registers a new sending domain: it records the row, ensures a DKIM key
|
|
// exists on disk, and regenerates + reloads the OpenDKIM tables (product.md).
|
|
// name must already be normalised and validated by the caller. A duplicate
|
|
// returns store.ErrDomainExists.
|
|
//
|
|
// The registry row is written first so its UNIQUE constraint is the single
|
|
// arbiter of "already exists" (avoiding a check-then-act race). An existing
|
|
// on-disk key is reused rather than overwritten, so re-adding a domain whose DB
|
|
// row was lost keeps its published DNS record valid. If key generation or the
|
|
// OpenDKIM rebuild fails, the row is rolled back so we never leave a registered
|
|
// domain that OpenDKIM cannot sign.
|
|
func (s *Service) Add(name string) (store.Domain, error) {
|
|
d, err := s.store.AddDomain(name, s.selector)
|
|
if err != nil {
|
|
return store.Domain{}, err
|
|
}
|
|
|
|
if _, err := s.odk.EnsureKey(d.Name, d.DKIMSelector); err != nil {
|
|
s.rollbackAdd(d.ID)
|
|
return store.Domain{}, err
|
|
}
|
|
if err := s.resync(); err != nil {
|
|
s.rollbackAdd(d.ID)
|
|
return store.Domain{}, err
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// rollbackAdd best-effort removes a half-created domain after a downstream
|
|
// failure. Errors here are logged by the caller's returned error path; the key
|
|
// (if freshly generated) is left in place harmlessly and reused on retry.
|
|
func (s *Service) rollbackAdd(id int64) {
|
|
_ = s.store.DeleteDomain(id)
|
|
}
|
|
|
|
// Delete removes a domain and everything bound to it (product.md). The
|
|
// order matters: the applications' SASL accounts are cleared first, while their
|
|
// logins are still in the registry; then the registry rows (applications and
|
|
// their addresses) go via the DB cascade; then the OpenDKIM tables and the
|
|
// Postfix sender map are rebuilt from what remains — so OpenDKIM stops signing
|
|
// and Postfix stops authorising the domain's senders — before the DKIM key is
|
|
// deleted.
|
|
func (s *Service) Delete(id int64) error {
|
|
d, err := s.store.GetDomain(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.apps.PurgeDomainSASL(id); err != nil {
|
|
return fmt.Errorf("clear SASL accounts for %s: %w", d.Name, err)
|
|
}
|
|
// Drop the domain's own level-2 limit and those of its applications while the
|
|
// application rows still exist (the cleanup query joins them). rate_limits
|
|
// has no cascade of its own (ref_id is a plain integer, guide § Rate
|
|
// limiting; architecture.md § Persistence).
|
|
if err := s.store.DeleteRateLimitsForDomain(id); err != nil {
|
|
return fmt.Errorf("clear rate limits for %s: %w", d.Name, err)
|
|
}
|
|
if err := s.store.DeleteDomain(id); err != nil {
|
|
return err
|
|
}
|
|
if err := s.resync(); err != nil {
|
|
return err
|
|
}
|
|
if err := s.apps.Resync(); err != nil {
|
|
return fmt.Errorf("rebuild sender map after deleting %s: %w", d.Name, err)
|
|
}
|
|
// A sending domain named like the server shared its key with the server's
|
|
// own mail. The resync above kept the name in the tables under the default
|
|
// selector: the same key if that was the domain's selector, a new one -
|
|
// with a new record to publish, which Settings shows - if it was not.
|
|
// Either way the directory holds the key now in use.
|
|
if d.Name == s.serverName {
|
|
return nil
|
|
}
|
|
if err := s.odk.RemoveKey(d.Name); err != nil {
|
|
// The domain is gone from the registry and tables; a leftover key
|
|
// directory is harmless. Surface it so it is not silently ignored.
|
|
return fmt.Errorf("domain deleted but key cleanup failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DKIMRecord returns the DNS TXT record to publish for a domain (product.md).
|
|
func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
|
|
return s.odk.Record(d.Name, d.DKIMSelector)
|
|
}
|
|
|
|
// RateLimit returns the domain-level differentiated rate limit (guide § Rate
|
|
// limiting), and whether one is configured, for the domain's edit form.
|
|
func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
|
|
return s.store.GetRateLimit(store.RateLimitScopeDomain, domainID)
|
|
}
|
|
|
|
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
|
// numbers (security.md); the milter reads the row live, so no reload is needed.
|
|
func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error {
|
|
rl.Scope = store.RateLimitScopeDomain
|
|
rl.RefID = domainID
|
|
return s.store.SetRateLimit(rl)
|
|
}
|
|
|
|
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
|
|
// only (guide § Rate limiting).
|
|
func (s *Service) ClearRateLimit(domainID int64) error {
|
|
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
|
|
}
|
|
|
|
// Resync regenerates the OpenDKIM tables from the registry and reloads
|
|
// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP
|
|
// surface) and doubles as a recovery path if the tables ever drift from the
|
|
// database.
|
|
func (s *Service) Resync() error {
|
|
return s.resync()
|
|
}
|
|
|
|
// resync rebuilds KeyTable/SigningTable from the current domain set and reloads.
|
|
func (s *Service) resync() error {
|
|
domains, err := s.store.ListDomains()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
signing := make([]SigningDomain, 0, len(domains)+1)
|
|
registered := false
|
|
for _, d := range domains {
|
|
signing = append(signing, SigningDomain{Name: d.Name, Selector: d.DKIMSelector})
|
|
if d.Name == s.serverName {
|
|
registered = true
|
|
}
|
|
}
|
|
// The server's own name, for the panel's notifications - unless it is a
|
|
// sending domain already and so in the tables above.
|
|
if s.serverName != "" && !registered {
|
|
if _, err := s.odk.EnsureKey(s.serverName, s.selector); err != nil {
|
|
return fmt.Errorf("dkim key for %s: %w", s.serverName, err)
|
|
}
|
|
signing = append(signing, SigningDomain{Name: s.serverName, Selector: s.selector})
|
|
}
|
|
return s.odk.Rebuild(signing)
|
|
}
|