test / test (push) Canceled after 0s
A message's From: line is held against the application that sends it, under the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by that line, so until now an application could have mail signed as another domain of the server or as the server itself. The check (internal/milter/sender.go) - A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in smtpd_milters and strict: default_action=tempfail. The journal milter keeps its socket and stays fail-open. postfix-wrapper waits for the new socket. - It reads no database: what an application may send as comes from Postfix's own sender_login_maps, what the server signs for from OpenDKIM's SigningTable. Both are read again when they change; one that cannot be read defers mail. - One From: field, one address, allowed by the envelope's lookup; then no other name of the SigningTable may appear anywhere in the raw line. Refused: 550 5.7.1 naming the line, reduced to printable ASCII. - A refusal is a send-log row with reason "from" and the header address (migration 0006), written by another goroutine and not waited for. The journal's refusals record their reason too (limit, client), and the notifier takes only a limit's. What it costs (docs/plans/from-header-binding.md, Statistics) - The role marks itself alive every 2 s; at start the gap since the mark is downtime, counted from Postfix's start if later and not at all when supervisord says Postfix is not running. - The log tailer counts the connections Postfix turned away: a warning that names the socket, then milter-reject "Service unavailable" from the same smtpd. Seen on the stand: the client is refused from CONNECT on and leaves at AUTH (454), so no MAIL line is ever written. - sender_check_days, a row per UTC day, kept a year. Pages - Health: the card "Sender check", the socket's row, and the cards layout - boxes numbered by importance in two stacks read in rows; the left stack polls, the three readings of the right one are swapped in by id. - Outbound log: a refused row says why in its tag (wrong From, wrong client, rate-limited); the message's page shows the From: line and what the application may send as. - Help: topic "Sender check", word for word the mockup's. Found by the stand, fixed here: the names for the second look came from the sender bindings, and a domain with a key and no application yet was not among them. e2e: negative_journal_milter_fail_open is gone - it proved that mail passes with the panel down, which is no longer so. In its place sender_check_holds_the_from_line (another domain's From:, the hostname's, a stranger's, an address off the list, another sender in the display name - refused; own addresses - delivered) and panel_down_defers_mail (4xx with the panel stopped, the container alive, mail again after, downtime and one deferred message on Health, the card warning). Test server, this tree before the last cosmetic edits (a fixture row, a fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s). Locally after them: internal/web/view ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
404 lines
14 KiB
Go
404 lines
14 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
|
|
// The sender check's socket; the default mirrors build/postfix-config.sh.
|
|
senderSocket 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"),
|
|
senderSocket: envDefault("SENDER_CHECK_SOCKET", "/run/selfpost/sender.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)
|
|
}
|
|
}
|
|
// The key the filter signs with has to be in the shared directory before
|
|
// the filter reads it, whether or not a filter is attached yet.
|
|
if cfg.inboundEnabled {
|
|
if _, err := inbound.FilterKey(cfg.antispamDir); err != nil {
|
|
log.Printf("filter key 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
|
|
// Room for every role to report a failure without blocking: seven at most.
|
|
errc := make(chan error, 7)
|
|
|
|
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) }},
|
|
{"sender-check", func(ctx context.Context) error { return serveSenderCheck(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, &logtail.SenderCheck{Store: st, Socket: cfg.senderSocket})
|
|
}},
|
|
{"rate-limit-recalc", func(ctx context.Context) error {
|
|
runAutoRateLimitRecalc(ctx, cfg, st)
|
|
return nil
|
|
}},
|
|
{"notifier", func(ctx context.Context) error { return runNotifier(ctx, cfg, st) }},
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|