release: 1.9.2: Review pass: port-25 milters, DMARC validation, rate-limit fix
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission milter chain, DMARC reports are validated before they are stored, and the auto rate limit is correct for a non-default level-1 window. Schema migration `0010` (index only). Upgrading is a tag bump. Added: - SQLite migration `0010_send_log_app_login_index.sql` — - DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML - e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration Changed: - README hero line and a **Positioning** block in `docs/product.md` record the Fixed: - The setup account's password is hashed at bcrypt cost `12` like every other - Login/setup rate limiter: protecting blocked buckets from eviction is now - `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of - [schema-migrations.md](docs/schema-migrations.md): the schema section was - postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second … (11 more in CHANGELOG) Changed: - [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code - Extracted `internal/configsafe` — the empty-value and forbidden-character - Build, deploy and migration comments no longer cite the archived - Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through - Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and … (7 more in CHANGELOG) Removed: - Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs - Plan documents for already-shipped features: `domain-stats-auto-ratelimit`, Full detail: CHANGELOG.md (1.9.2).
This commit is contained in:
@@ -29,14 +29,14 @@ const (
|
||||
// hashed — so it can be shown exactly once at creation/regeneration
|
||||
// (security.md). Addresses is populated only in 'list' mode.
|
||||
type Application struct {
|
||||
ID int64
|
||||
DomainID int64
|
||||
Login string
|
||||
AddressMode string
|
||||
CreatedAt time.Time
|
||||
Addresses []string
|
||||
AuthIPRestrict bool // when true, only AuthAllowedIPs may submit as this login
|
||||
AuthAllowedIPs []string // client IPs permitted when AuthIPRestrict is set
|
||||
ID int64
|
||||
DomainID int64
|
||||
Login string
|
||||
AddressMode string
|
||||
CreatedAt time.Time
|
||||
Addresses []string
|
||||
AuthIPRestrict bool // when true, only AuthAllowedIPs may submit as this login
|
||||
AuthAllowedIPs []string // client IPs permitted when AuthIPRestrict is set
|
||||
}
|
||||
|
||||
// AllowsAuthFromIP reports whether a client at ip may submit mail authenticated
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"modernc.org/sqlite"
|
||||
@@ -182,3 +183,17 @@ func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DomainExists reports whether a sending domain with this name is configured.
|
||||
// Used by the DMARC ingest path to refuse reports for domains this relay does
|
||||
// not send for (security.md: report XML is attacker-supplied input).
|
||||
func (s *Store) DomainExists(name string) (bool, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM domains WHERE name = ? COLLATE NOCASE", strings.TrimSpace(name),
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("domain exists %q: %w", name, err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
-- Initial SelfPost schema (spec 9). One SQLite file under /data holds the whole
|
||||
-- panel state so a single directory backup/restore is sufficient (spec 7.5.A).
|
||||
-- Initial SelfPost schema. One SQLite file under /data holds the whole panel
|
||||
-- state so a single directory backup/restore is sufficient.
|
||||
|
||||
-- Single administrator account (spec 7.6.1). Exactly one row is allowed; the
|
||||
-- presence of that row is what marks primary setup as complete, which is why
|
||||
-- the /setup route disappears once it exists.
|
||||
-- Single administrator account. Exactly one row is allowed; the presence of
|
||||
-- that row is what marks primary setup as complete, which is why the /setup
|
||||
-- route disappears once it exists.
|
||||
CREATE TABLE admin (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL,
|
||||
@@ -17,8 +17,8 @@ CREATE TABLE settings (
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sending domains managed through the panel (spec 4.1). DKIM keys themselves
|
||||
-- live on disk under /data; this row records the selector and metadata.
|
||||
-- Sending domains managed through the panel. DKIM keys themselves live on disk
|
||||
-- under /data; this row records the selector and metadata.
|
||||
CREATE TABLE domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@@ -26,8 +26,8 @@ CREATE TABLE domains (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Applications bound to a domain (spec 4.1). address_mode is either the domain
|
||||
-- wildcard or an explicit address list; the SASL login is globally unique.
|
||||
-- Applications bound to a domain. address_mode is either the domain wildcard or
|
||||
-- an explicit address list; the SASL login is globally unique.
|
||||
CREATE TABLE applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
@@ -37,7 +37,7 @@ CREATE TABLE applications (
|
||||
);
|
||||
|
||||
-- Explicit sender addresses for applications in 'list' mode. Each address must
|
||||
-- belong to the application's domain (validated in the panel, spec 7.6.2).
|
||||
-- belong to the application's domain (validated in the panel).
|
||||
CREATE TABLE application_addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
@@ -45,8 +45,8 @@ CREATE TABLE application_addresses (
|
||||
UNIQUE (application_id, address)
|
||||
);
|
||||
|
||||
-- Structured send log (spec 7.3). One row per (queue-id, recipient); the
|
||||
-- log-tailer advances status from queued to a final state.
|
||||
-- Structured send log. One row per (queue-id, recipient); the log-tailer
|
||||
-- advances status from queued to a final state.
|
||||
CREATE TABLE send_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
queue_id TEXT,
|
||||
@@ -63,8 +63,8 @@ CREATE INDEX idx_send_log_queue_id ON send_log (queue_id);
|
||||
CREATE INDEX idx_send_log_domain ON send_log (domain);
|
||||
CREATE INDEX idx_send_log_created_at ON send_log (created_at);
|
||||
|
||||
-- Differentiated rate limits per domain/application (spec 7.4). Both the IP
|
||||
-- binding and the message limit are optional.
|
||||
-- Differentiated rate limits per domain/application. Both the IP binding and
|
||||
-- the message limit are optional.
|
||||
CREATE TABLE rate_limits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
-- Panel login sessions (spec 7.6.6, plan B.1). Persisted so a login survives a
|
||||
-- container restart or redeploy; only the SHA-256 of the session token is
|
||||
-- stored, never the token itself, so a stolen database file cannot be used to
|
||||
-- sign in. expires_at implements the sliding idle timeout: it is pushed
|
||||
-- forward on activity rather than being fixed at creation time.
|
||||
-- Panel login sessions. Persisted so a login survives a container restart or
|
||||
-- redeploy; only the SHA-256 of the session token is stored, never the token
|
||||
-- itself, so a stolen database file cannot be used to sign in. expires_at
|
||||
-- implements the sliding idle timeout: it is pushed forward on activity rather
|
||||
-- than being fixed at creation time.
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Send-log lookups by application login run on two hot paths: the journal
|
||||
-- milter counts messages for a level-2 rate limit on every MAIL FROM, and the
|
||||
-- domain page computes 30-day statistics per application on every render.
|
||||
-- 0001 indexed queue_id, domain and created_at, but not app_login, so both
|
||||
-- fell back to scanning the created_at range. The composite matches the shape
|
||||
-- of those queries (equality on app_login, range on created_at).
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_send_log_app_login_created_at
|
||||
ON send_log (app_login, created_at);
|
||||
@@ -116,7 +116,7 @@ func (s *Store) recalcAutoRateLimit(rl RateLimit, retentionDays, l1Max, l1Window
|
||||
return fmt.Errorf("unknown scope %q", rl.Scope)
|
||||
}
|
||||
|
||||
maxMsgs := computeAutoMaxMessages(stats, mult, l1Max)
|
||||
maxMsgs := computeAutoMaxMessages(stats, mult, l1Max, l1Window)
|
||||
|
||||
rl.MaxMessages = maxMsgs
|
||||
rl.WindowSeconds = l1Window
|
||||
@@ -128,14 +128,26 @@ func (s *Store) recalcAutoRateLimit(rl RateLimit, retentionDays, l1Max, l1Window
|
||||
return s.SetRateLimit(rl)
|
||||
}
|
||||
|
||||
func computeAutoMaxMessages(stats SendStats, multiplier float64, l1Max int) int {
|
||||
// computeAutoMaxMessages turns an average send rate into a ceiling for one
|
||||
// level-1 window. AvgPerHour is per hour, the ceiling counts messages per
|
||||
// windowSeconds, so the rate is scaled to the window before the multiplier is
|
||||
// applied — otherwise a window other than the default 3600s would silently
|
||||
// shift the effective allowance by windowSeconds/3600. The result is never
|
||||
// below 1 for a domain that has sent anything, and never above level 1.
|
||||
func computeAutoMaxMessages(stats SendStats, multiplier float64, l1Max, windowSeconds int) int {
|
||||
if stats.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
max := int(math.Ceil(stats.AvgPerHour * multiplier))
|
||||
if windowSeconds <= 0 {
|
||||
windowSeconds = 3600
|
||||
}
|
||||
perWindow := stats.AvgPerHour * float64(windowSeconds) / 3600
|
||||
max := int(math.Ceil(perWindow * multiplier))
|
||||
if max < 1 {
|
||||
max = 1
|
||||
}
|
||||
if max > l1Max {
|
||||
max = l1Max
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
|
||||
@@ -154,8 +154,8 @@ func TestRateLimitActive(t *testing.T) {
|
||||
inactive := []RateLimit{
|
||||
{},
|
||||
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
||||
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
|
||||
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
|
||||
}
|
||||
for i, rl := range inactive {
|
||||
if rl.Active() {
|
||||
@@ -261,3 +261,40 @@ func TestAutoRateLimitZeroTrafficInactive(t *testing.T) {
|
||||
t.Fatalf("zero traffic auto should be inactive: %+v", rl)
|
||||
}
|
||||
}
|
||||
|
||||
// The auto ceiling counts messages per level-1 window, so the per-hour average
|
||||
// must be scaled to that window: half an hour of traffic, not a full hour's.
|
||||
func TestComputeAutoMaxMessagesScalesToWindow(t *testing.T) {
|
||||
stats := SendStats{Total: 100, AvgPerHour: 10}
|
||||
cases := []struct {
|
||||
name string
|
||||
windowSeconds int
|
||||
want int
|
||||
}{
|
||||
{"one hour", 3600, 20},
|
||||
{"half an hour", 1800, 10},
|
||||
{"two hours", 7200, 40},
|
||||
{"unset falls back to an hour", 0, 20},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := computeAutoMaxMessages(stats, 2.0, 1000, tc.windowSeconds)
|
||||
if got != tc.want {
|
||||
t.Fatalf("max = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAutoMaxMessagesBounds(t *testing.T) {
|
||||
if got := computeAutoMaxMessages(SendStats{}, 2.0, 1000, 3600); got != 0 {
|
||||
t.Fatalf("no traffic: max = %d, want 0 (inactive)", got)
|
||||
}
|
||||
// A trickle still has to leave room for one message.
|
||||
if got := computeAutoMaxMessages(SendStats{Total: 1, AvgPerHour: 0.001}, 1.0, 1000, 3600); got != 1 {
|
||||
t.Fatalf("trickle: max = %d, want 1", got)
|
||||
}
|
||||
if got := computeAutoMaxMessages(SendStats{Total: 100, AvgPerHour: 500}, 2.0, 100, 3600); got != 100 {
|
||||
t.Fatalf("capped: max = %d, want the level-1 ceiling 100", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type SendStats struct {
|
||||
Total int64
|
||||
PeakPerHour int64
|
||||
AvgPerHour float64
|
||||
WindowDays int // actual days queried (min(30, retention))
|
||||
WindowDays int // actual days queried (min(30, retention))
|
||||
WindowHours float64 // hours used for the average-rate denominator
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user