Step 5/12 of docs/plans/inbound-antispam-panel.md.
Store: AddSpamListEntry, ListSpamListEntries, DeleteSpamListEntry.
internal/inbound/lists.go validates and normalises an entry - sender
address, sender domain, client address or network (canonical text, an
IPv4-mapped address as IPv4, /0 refused) - and writes the lists as six
map files under /data/antispam (ANTISPAM_DIR), each atomically, 0644 in
a 0755 directory: the filter is another user in another container. All
six are written on every change and on every start with the relay on,
empty ones included. An entry whose maps could not be written is taken
back out of the table. The writer refuses a stored value with
whitespace, a newline or # in it.
The plan's allow.map / deny.map became one file per list and entry
type: to rspamd an IP map and a sender map are different map types.
deploy/antispam/rspamd/multimap.conf holds the rules; the fragment
mounts it and ./data/antispam into the sidecar. Deny is a prefilter
reject, allow a symbol weighing -100. Tried in a throwaway rspamd 3.11
container on the test server:
- with allow as a prefilter accept, an address on both lists was
accepted and an IP on both was refused - the rule that ran first
decided; with the weight, deny won in all five mixed cases;
- IPv4, IPv6, address (any case), domain (not its subdomains) match;
- a rewritten file is reread in about ten seconds, no restart;
- files that appear after rspamd started are loaded the same way.
No page edits the lists yet (step 6).
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - with the relay on
/data/antispam is 755 and holds the six maps, 644, empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
5.4 KiB
Go
159 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/mixeme/selfpost/internal/app"
|
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
|
"github.com/mixeme/selfpost/internal/dmarc"
|
|
"github.com/mixeme/selfpost/internal/domain"
|
|
"github.com/mixeme/selfpost/internal/inbound"
|
|
"github.com/mixeme/selfpost/internal/postfix"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
"github.com/mixeme/selfpost/internal/web"
|
|
)
|
|
|
|
// mailStack is the panel's domain and application services plus the on-disk
|
|
// mail-path adapters they write through.
|
|
type mailStack struct {
|
|
Domains *domain.Service
|
|
Apps *app.Service
|
|
Inbound *inbound.Service
|
|
Lists *inbound.Lists
|
|
DMARC *dmarc.Service
|
|
pf *postfix.Postfix
|
|
odk *domain.OpenDKIM
|
|
inboundEnabled bool
|
|
dmarcEnabled bool
|
|
}
|
|
|
|
func newMailStack(cfg config, st *store.Store) *mailStack {
|
|
pf := postfix.New(cfg.postfixDir)
|
|
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
|
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
|
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
|
inb := inbound.NewService(st, pf)
|
|
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
|
return &mailStack{
|
|
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
|
|
Lists: inbound.NewLists(st, cfg.antispamDir),
|
|
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
|
}
|
|
}
|
|
|
|
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
|
// reloads both daemons — the same work as the Status page's Reload button.
|
|
func (m *mailStack) Resync() error {
|
|
if err := m.Domains.Resync(); err != nil {
|
|
return fmt.Errorf("opendkim resync: %w", err)
|
|
}
|
|
if err := m.Apps.Resync(); err != nil {
|
|
return fmt.Errorf("postfix resync: %w", err)
|
|
}
|
|
if m.inboundEnabled {
|
|
if err := m.Inbound.Resync(); err != nil {
|
|
return fmt.Errorf("inbound maps resync: %w", err)
|
|
}
|
|
// The filter's configuration names all six list files; they exist from
|
|
// the first start, empty until an entry is added.
|
|
if err := m.Lists.Sync(); err != nil {
|
|
return fmt.Errorf("filter lists resync: %w", err)
|
|
}
|
|
}
|
|
if m.dmarcEnabled {
|
|
if err := m.DMARC.Resync(); err != nil {
|
|
return fmt.Errorf("dmarc maps resync: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mailStack) skipReloadForTest() {
|
|
m.pf.SetReloadHook(func() error { return nil })
|
|
m.odk.SetReloadHook(func() error { return nil })
|
|
}
|
|
|
|
// resyncAfterRestore runs one mail-path Resync on the first boot after a
|
|
// backup restore. testNoReload skips the supervisord reload step so restore
|
|
// tests can verify file regeneration without a running mail stack.
|
|
func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
|
|
ms := newMailStack(cfg, st)
|
|
if testNoReload {
|
|
ms.skipReloadForTest()
|
|
}
|
|
return ms.Resync()
|
|
}
|
|
|
|
// newPanel wires the panel's services over the shared database handle and
|
|
// builds the HTTP application from cfg. It is the composition of the panel as
|
|
// the environment describes it, with nothing bound to a port yet.
|
|
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
|
ms := newMailStack(cfg, st)
|
|
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
|
// effective config, including a manual override; the panel keeps this
|
|
// snapshot for the process lifetime (architecture.md).
|
|
retryPolicy := postfix.LoadRetryPolicy()
|
|
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{
|
|
Hostname: cfg.hostname,
|
|
CookieSecure: cfg.cookieSecure,
|
|
SubmissionEnabled: cfg.submissionEnabled,
|
|
MailLogPath: cfg.mailLog,
|
|
DataDir: cfg.dataDir,
|
|
DBPath: cfg.dbPath,
|
|
DeployRoot: cfg.deployRoot,
|
|
Version: buildinfo.Version,
|
|
TrustedProxyCIDRs: cfg.trustedProxies,
|
|
TLSCertFile: cfg.tlsCertFile,
|
|
OpenDKIMSocket: cfg.opendkimSocket,
|
|
JournalSocket: cfg.journalSocket,
|
|
SessionIdleDays: cfg.sessionIdleDays,
|
|
DNSResolvers: cfg.dnsResolvers,
|
|
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
|
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
|
RetryPolicy: retryPolicy,
|
|
InboundEnabled: cfg.inboundEnabled,
|
|
InboundAntispamMilter: cfg.inboundAntispamMilter,
|
|
InboundAntispamAction: cfg.inboundAntispamAction,
|
|
DMARCEnabled: cfg.dmarcEnabled,
|
|
SendLogRetentionEnvDefault: cfg.retentionDays,
|
|
}, cfg.setupTokenPath)
|
|
}
|
|
|
|
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
|
// the database handle shared by all roles: setup, login and the authenticated
|
|
// panel surface (security.md).
|
|
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
|
srvApp, err := newPanel(cfg, st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := srvApp.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.httpAddr,
|
|
Handler: srvApp.Handler(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
// Shut the server down cleanly when the process is asked to stop.
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(shutdownCtx)
|
|
}()
|
|
|
|
log.Printf("http panel listening on %s", cfg.httpAddr)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|