test / test (push) Has been cancelled
Review pass over the 1.4.0-1.9.1 work. Port 25 no longer inherits main.cf's submission milter chain: smtpd_milters is always overridden on smtp/inet, to the antispam milter or to nothing. Without that, OpenDKIM (strict, tempfail) gated relayed mail during a signing outage, and the journal-milter filed inbound mail into the outbound send log under the sender's domain -- so Deliveries showed other people's mail and a forged From: consumed that domain's level-2 budget. architecture.md already described the intended behaviour; the code now matches. DMARC reports are checked before they are stored: the policy domain must be a configured sending domain, and must match the +tag of a per-domain hosted address. The rua address form is predictable, so anyone able to reach it could file reports under any domain and evict genuine ones through the retention cap. Zip attachments are now decompressed as well as gzip. The auto level-2 ceiling is scaled to the level-1 window before the multiplier is applied; any window other than 3600s shifted the allowance by window/3600. Migration 0010 indexes send_log(app_login, created_at) -- both the per-MAIL FROM rate-limit count and the per-application statistics were scanning the created_at range. e2e now regenerates the Postfix config in-container for all four port-25 flag combinations and requires postfix check to pass; the 1.9.1 crash-loop shipped in 1.4.0 and survived three releases because only the flags-off path was exercised. Migration 0009 stays as shipped -- its conversion branch only fires for a database carrying application-scope allowed_ips from before 1.9.0. The schema reference notes that the old list was permissive and the new column is restrictive; the guide documents the allow-list as a sending control on a fail-open path, not an authentication boundary. Also: gofmt across cmd/ and internal/, unused duplicate validators dropped from internal/web/validate, export no longer swallows store errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
4.1 KiB
Go
154 lines
4.1 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"time"
|
|
)
|
|
|
|
// RecalcAllAutoRateLimits recomputes max_messages for every auto-mode limit.
|
|
// Store errors for individual rows are logged and skipped (fail-open on the
|
|
// last successfully written limit). Returns how many rows were updated.
|
|
func (s *Store) RecalcAllAutoRateLimits(retentionDays, l1Max, l1Window int) (int, error) {
|
|
rows, err := s.listAutoRateLimits()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var updated int
|
|
for _, rl := range rows {
|
|
if err := s.recalcAutoRateLimit(rl, retentionDays, l1Max, l1Window); err != nil {
|
|
log.Printf("store: auto rate-limit recalc %s ref %d: %v", rl.Scope, rl.RefID, err)
|
|
continue
|
|
}
|
|
updated++
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// RecalcAutoRateLimit recomputes one auto-mode limit. Returns an error when
|
|
// the row is missing or not in auto mode.
|
|
func (s *Store) RecalcAutoRateLimit(scope string, refID int64, retentionDays, l1Max, l1Window int) error {
|
|
rl, ok, err := s.GetRateLimit(scope, refID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok || !rl.IsAuto() {
|
|
return fmt.Errorf("rate limit not in auto mode")
|
|
}
|
|
rl.Scope, rl.RefID = scope, refID
|
|
return s.recalcAutoRateLimit(rl, retentionDays, l1Max, l1Window)
|
|
}
|
|
|
|
func (s *Store) listAutoRateLimits() ([]RateLimit, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT scope, ref_id, allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
|
|
FROM rate_limits WHERE mode = ?`, RateLimitModeAuto,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list auto rate limits: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []RateLimit
|
|
for rows.Next() {
|
|
var scope string
|
|
var refID int64
|
|
var ips sql.NullString
|
|
var maxMsgs, windowSecs sql.NullInt64
|
|
var mode sql.NullString
|
|
var autoMult sql.NullFloat64
|
|
var autoUpdated sql.NullString
|
|
if err := rows.Scan(&scope, &refID, &ips, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
|
|
return nil, fmt.Errorf("scan auto rate limit: %w", err)
|
|
}
|
|
rl := RateLimit{
|
|
Scope: scope,
|
|
RefID: refID,
|
|
AllowedIPs: splitIPs(ips.String),
|
|
MaxMessages: int(maxMsgs.Int64),
|
|
WindowSeconds: int(windowSecs.Int64),
|
|
Mode: mode.String,
|
|
}
|
|
if autoMult.Valid {
|
|
rl.AutoMultiplier = autoMult.Float64
|
|
}
|
|
if autoUpdated.Valid {
|
|
rl.AutoUpdatedAt, _ = time.Parse(time.RFC3339, autoUpdated.String)
|
|
}
|
|
out = append(out, rl)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) recalcAutoRateLimit(rl RateLimit, retentionDays, l1Max, l1Window int) error {
|
|
if !rl.IsAuto() {
|
|
return fmt.Errorf("not auto mode")
|
|
}
|
|
mult := rl.AutoMultiplier
|
|
if mult <= 0 {
|
|
mult = DefaultAutoMultiplier
|
|
}
|
|
|
|
var stats SendStats
|
|
|
|
switch rl.Scope {
|
|
case RateLimitScopeDomain:
|
|
d, err := s.GetDomain(rl.RefID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stats, err = s.DomainSendStats(d.Name, retentionDays, d.CreatedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case RateLimitScopeApp:
|
|
a, err := s.GetApplication(rl.RefID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stats, err = s.AppSendStats(a.Login, retentionDays, a.CreatedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown scope %q", rl.Scope)
|
|
}
|
|
|
|
maxMsgs := computeAutoMaxMessages(stats, mult, l1Max, l1Window)
|
|
|
|
rl.MaxMessages = maxMsgs
|
|
rl.WindowSeconds = l1Window
|
|
rl.AutoUpdatedAt = time.Now().UTC()
|
|
if rl.AutoMultiplier <= 0 {
|
|
rl.AutoMultiplier = mult
|
|
}
|
|
|
|
return s.SetRateLimit(rl)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|