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>
391 lines
13 KiB
Go
391 lines
13 KiB
Go
// Command panel is the SelfPost control panel. This single binary combines
|
|
// several roles (architecture.md § Image and processes) as a supervised
|
|
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
|
// the rate-limit checks.
|
|
//
|
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
|
|
"github.com/mixeme/selfpost/internal/backup"
|
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
|
"github.com/mixeme/selfpost/internal/inbound"
|
|
"github.com/mixeme/selfpost/internal/logtail"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
)
|
|
|
|
func main() {
|
|
if isDMARCIngestInvocation() {
|
|
log.SetFlags(log.LstdFlags | log.LUTC)
|
|
log.SetPrefix("dmarc-ingest: ")
|
|
if err := runDMARCIngestMode(); err != nil {
|
|
log.Printf("ingest failed: %v", err)
|
|
os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
|
|
}
|
|
return
|
|
}
|
|
|
|
showVersion := flag.Bool("version", false, "print version and exit")
|
|
flag.Parse()
|
|
|
|
if *showVersion {
|
|
fmt.Println(buildinfo.Version)
|
|
return
|
|
}
|
|
|
|
log.SetFlags(log.LstdFlags | log.LUTC)
|
|
log.SetPrefix("panel: ")
|
|
|
|
if err := run(); err != nil {
|
|
log.Fatalf("fatal: %v", err)
|
|
}
|
|
}
|
|
|
|
// config holds the runtime knobs the panel reads from the environment. Every
|
|
// value has a safe default so the binary also runs outside the container.
|
|
type config struct {
|
|
httpAddr string
|
|
journalSocket string
|
|
mailLog string
|
|
retentionDays int
|
|
|
|
dataDir string
|
|
dbPath string
|
|
manifestPath string
|
|
setupTokenPath string
|
|
hostname string
|
|
cookieSecure bool
|
|
submissionEnabled bool
|
|
trustedProxies []*net.IPNet
|
|
sessionIdleDays int
|
|
dnsResolvers []string
|
|
rateLimitMessagesPerIP int
|
|
rateLimitWindowSeconds int
|
|
|
|
// Read-only inputs to the panel's status page: the certificate Postfix
|
|
// serves and the two milter sockets it connects to. The defaults mirror
|
|
// build/postfix-config.sh, so the status page checks exactly what Postfix
|
|
// was configured with.
|
|
tlsCertFile string
|
|
opendkimSocket string
|
|
|
|
opendkimDir string
|
|
dkimSelectorDef string
|
|
|
|
saslDBPath string
|
|
saslRealm string
|
|
postfixDir string
|
|
deployRoot string
|
|
// Where the filter lists are written as map files for the external spam
|
|
// filter; the antispam compose fragment mounts it into the sidecar.
|
|
antispamDir string
|
|
|
|
inboundEnabled bool
|
|
// The optional milter on the inbound listener (INBOUND_ANTISPAM_MILTER) and
|
|
// what Postfix does when it is down. Read-only: build/postfix-config.sh is
|
|
// what attaches it; the panel only says so. See antispamAction.
|
|
inboundAntispamMilter string
|
|
inboundAntispamAction string
|
|
// The inbound journal milter's socket; the default mirrors
|
|
// build/postfix-config.sh. Served only while the inbound relay is on.
|
|
inboundJournalSocket string
|
|
// Retention of the inbound journal until Server > Settings says otherwise.
|
|
inboundSpamLogRetentionDays int
|
|
dmarcEnabled bool
|
|
}
|
|
|
|
func loadConfig() config {
|
|
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
|
return config{
|
|
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
|
|
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
|
|
// Postfix's delivery log, under /data so the lines that resolve a
|
|
// "queued" send-log row outlive the container. The default must match
|
|
// maillog_file in build/postfix-config.sh.
|
|
mailLog: envDefault("MAIL_LOG", "/data/log/mail.log"),
|
|
// Send-log retention window (architecture.md § Persistence).
|
|
// Non-positive/invalid falls back to the 90-day default inside the
|
|
// log-tailer.
|
|
retentionDays: envInt("SEND_LOG_RETENTION_DAYS", 90),
|
|
|
|
dataDir: dataDir,
|
|
dbPath: envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")),
|
|
manifestPath: filepath.Join(dataDir, backup.ManifestName),
|
|
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
|
|
hostname: os.Getenv("SELFPOST_HOSTNAME"),
|
|
// Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a
|
|
// development-only escape hatch for testing over plain HTTP.
|
|
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
|
// Whether this deployment also runs the 587 submission listener. The
|
|
// panel only displays it as a client connection setting; the comparison
|
|
// matches postfix-config.sh, which enables the listener on "true" alone.
|
|
submissionEnabled: os.Getenv("SUBMISSION_ENABLE") == "true",
|
|
// Reverse-proxy addresses allowed to supply X-Forwarded-For for
|
|
// rate-limiting. Empty by default: an untrusted peer's
|
|
// XFF header is trivially forgeable, so it's ignored unless the panel is
|
|
// told which proxy to trust.
|
|
trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")),
|
|
// Sliding session idle timeout (security.md, plan B.1). Non-positive/invalid
|
|
// falls back to the 7-day default inside internal/web.
|
|
sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7),
|
|
// Recursive resolvers the deliverability checks query directly. Empty
|
|
// means dnscheck's public defaults; a closed network names its own here.
|
|
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
|
|
|
|
// Level-1 anvil defaults match build/postfix-config.sh / guide.md.
|
|
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
|
|
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
|
|
|
|
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
|
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
|
|
|
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
|
|
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
|
|
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
|
|
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
|
|
|
|
// Application SASL accounts and the Postfix sender map (architecture.md §
|
|
// Mail path), both under /data so they survive restarts. The SASL realm
|
|
// defaults to the server hostname so account identities line up with
|
|
// Postfix's SASL configuration; it falls back to localhost outside the
|
|
// container.
|
|
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
|
saslRealm: saslRealm(),
|
|
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
|
|
|
antispamDir: envDefault("ANTISPAM_DIR", filepath.Join(dataDir, "antispam")),
|
|
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
|
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
|
|
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
|
|
inboundAntispamMilter: os.Getenv("INBOUND_ANTISPAM_MILTER"),
|
|
inboundAntispamAction: antispamAction(os.Getenv("INBOUND_ANTISPAM_MILTER_ACTION")),
|
|
inboundJournalSocket: envDefault("INBOUND_JOURNAL_MILTER_SOCKET", "/run/selfpost/inbound-journal.sock"),
|
|
|
|
inboundSpamLogRetentionDays: envInt("INBOUND_SPAM_LOG_RETENTION_DAYS", store.InboundSpamLogRetentionDaysDefault),
|
|
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
|
|
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
|
|
}
|
|
}
|
|
|
|
// antispamAction reads INBOUND_ANTISPAM_MILTER_ACTION the way
|
|
// build/postfix-config.sh does: empty means accept, and accept and tempfail are
|
|
// the only other values it lets the container start with. Anything else is
|
|
// returned as "" — the panel says nothing about the action rather than guess.
|
|
func antispamAction(raw string) string {
|
|
switch raw {
|
|
case "", "accept":
|
|
return "accept"
|
|
case "tempfail":
|
|
return "tempfail"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// saslRealm chooses the realm new SASL accounts live under. It mirrors the
|
|
// hostname Postfix's SASL layer uses so a client authenticating with a bare
|
|
// login resolves to the right account.
|
|
func saslRealm() string {
|
|
if r := os.Getenv("SASL_REALM"); r != "" {
|
|
return r
|
|
}
|
|
if h := os.Getenv("SELFPOST_HOSTNAME"); h != "" {
|
|
return h
|
|
}
|
|
return "localhost"
|
|
}
|
|
|
|
func envDefault(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// envInt reads an integer environment variable, returning def if it is unset or
|
|
// not a valid integer.
|
|
func envInt(key string, def int) int {
|
|
if v := os.Getenv(key); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
return n
|
|
}
|
|
log.Printf("ignoring invalid %s=%q, using %d", key, v, def)
|
|
}
|
|
return def
|
|
}
|
|
|
|
// parseTrustedProxies parses a comma-separated list of CIDRs (bare IPs are
|
|
// accepted and treated as /32 or /128). Invalid entries are logged and
|
|
// skipped rather than failing startup, matching envInt's tolerance of
|
|
// misconfiguration.
|
|
func parseTrustedProxies(raw string) []*net.IPNet {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
var nets []*net.IPNet
|
|
for _, part := range strings.Split(raw, ",") {
|
|
cidr := strings.TrimSpace(part)
|
|
if cidr == "" {
|
|
continue
|
|
}
|
|
if !strings.Contains(cidr, "/") {
|
|
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
|
|
cidr += "/32"
|
|
} else {
|
|
cidr += "/128"
|
|
}
|
|
}
|
|
_, n, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
log.Printf("ignoring invalid TRUSTED_PROXY_CIDR entry %q: %v", part, err)
|
|
continue
|
|
}
|
|
nets = append(nets, n)
|
|
}
|
|
return nets
|
|
}
|
|
|
|
// run starts the panel's three roles and blocks until a shutdown signal or the
|
|
// first fatal error from any role. A signal triggers a clean stop of all
|
|
// roles; a role error cancels the others and is returned so the process exits
|
|
// non-zero (letting supervisord/Docker see the failure — architecture.md §
|
|
// Image and processes).
|
|
func run() error {
|
|
cfg := loadConfig()
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
log.Printf("starting selfpost panel %s", buildinfo.Version)
|
|
|
|
// Restore version guard (architecture.md § Persistence): if a backup was
|
|
// extracted into /data, its manifest version must match this binary before we
|
|
// touch the database, so schema/format skew between versions cannot corrupt
|
|
// the restored state. A match consumes the manifest; its absence is the
|
|
// normal (non-restore) case.
|
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// One database handle shared by every role. The store serialises writes
|
|
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
|
|
// can all use it without stepping on each other under WAL.
|
|
st, err := store.Open(cfg.dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
|
|
if restored {
|
|
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
|
|
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
|
return err
|
|
}
|
|
} else if cfg.dmarcEnabled {
|
|
ms := newMailStack(cfg, st)
|
|
ms.pf.SetReloadHook(func() error { return nil })
|
|
if err := ms.DMARC.Resync(); err != nil {
|
|
log.Printf("dmarc maps bootstrap: %v", err)
|
|
}
|
|
}
|
|
|
|
// The filter lists as files, on every start: the spam filter's
|
|
// configuration names all six, and a data directory from before the lists
|
|
// existed has none. Not fatal - the lists are the filter's business, and
|
|
// the panel has mail to relay either way.
|
|
if cfg.inboundEnabled && !restored {
|
|
if err := inbound.NewLists(st, cfg.antispamDir).Sync(); err != nil {
|
|
log.Printf("filter lists bootstrap: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
|
|
return fmt.Errorf("bootstrap send-log retention: %w", err)
|
|
}
|
|
// With the inbound relay on, the tailer also writes the inbound journal's
|
|
// rows for refused mail and prunes that journal.
|
|
var inboundTail *logtail.Inbound
|
|
if cfg.inboundEnabled {
|
|
if err := st.EnsureInboundSpamLogRetentionDays(cfg.inboundSpamLogRetentionDays); err != nil {
|
|
return fmt.Errorf("bootstrap inbound journal retention: %w", err)
|
|
}
|
|
inboundTail = &logtail.Inbound{Store: st, Retention: func() int {
|
|
days, err := st.GetInboundSpamLogRetentionDays(cfg.inboundSpamLogRetentionDays)
|
|
if err != nil {
|
|
log.Printf("inbound journal retention: %v", err)
|
|
return cfg.inboundSpamLogRetentionDays
|
|
}
|
|
return days
|
|
}}
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
errc := make(chan error, 5)
|
|
|
|
roles := []struct {
|
|
name string
|
|
fn func(context.Context) error
|
|
}{
|
|
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
|
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
|
{"log-tailer", func(ctx context.Context) error {
|
|
return logtail.Run(ctx, cfg.mailLog, st, func() int {
|
|
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
|
|
if err != nil {
|
|
log.Printf("send-log retention: %v", err)
|
|
return cfg.retentionDays
|
|
}
|
|
return days
|
|
}, inboundTail)
|
|
}},
|
|
{"rate-limit-recalc", func(ctx context.Context) error {
|
|
runAutoRateLimitRecalc(ctx, cfg, st)
|
|
return nil
|
|
}},
|
|
}
|
|
|
|
if cfg.inboundEnabled {
|
|
roles = append(roles, struct {
|
|
name string
|
|
fn func(context.Context) error
|
|
}{"inbound-journal-milter", func(ctx context.Context) error { return serveInboundJournal(ctx, cfg, st) }})
|
|
}
|
|
|
|
for _, r := range roles {
|
|
wg.Add(1)
|
|
go func(name string, fn func(context.Context) error) {
|
|
defer wg.Done()
|
|
if err := fn(ctx); err != nil {
|
|
errc <- fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
}(r.name, r.fn)
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Printf("shutdown signal received, stopping")
|
|
wg.Wait()
|
|
log.Printf("panel stopped cleanly")
|
|
return nil
|
|
case err := <-errc:
|
|
log.Printf("role failed: %v", err)
|
|
stop() // cancel ctx so the other roles wind down
|
|
wg.Wait()
|
|
return err
|
|
}
|
|
}
|