test / test (push) Canceled after 0s
Technical review (Opus) and security review (Fable) of a59e3d4..cb5a4a2;
reports in the session's scratchpad. Nothing critical, high or medium; no
outright bug. Fixed:
- F2, F3 (0008 backfill, before it runs on the live database): rows are
filled by the rule new rows follow - the application's domain, then the
name's - and only by a domain and application that existed when the row
was written (created_at); the inbound log likewise. Test: the migration's
UPDATEs re-run on prepared rows (before the domain, after, null sender,
application gone, a stranger's name). Dry run on a copy of the live
database: 68/68 and 10/10 rows filled, none disagreeing with its login's
domain, integrity ok.
- V2, F4: an application's statistics and level-2 count are its login's
rows of its domain (appByLogin), and StatsWindow starts no earlier than
the entity's creation; the message page's "may send as" only for a login
still of the row's domain. Test: a login taken again in another domain
starts at zero.
- F7: MAIL FROM:<> is held to the domain limit of its login
(ApplicationByLogin fills DomainName).
- V3: an imported DKIM selector is held to DNS labels (validate.Selector).
- F5, F6: an imported auto limit's numbers are dropped and recalculated
right after the import; its mode is stored trimmed.
- V6: the store refuses a role that is neither global nor domain.
- F9: SaveUser keeps the stored hash when no new password is given (the
form no longer writes back the hash it read). F10: a reach naming a
domain deleted meanwhile is ErrNoSuchDomain, answered on the form.
- V4, F12: from-lines-other.txt - lines naming another sender ({other}) in
a quoted string, a quoted pair, capitals, a comment, an encoded word, a
quoted local part; the unit test wants each refused, the e2e
(lines_naming_another_sender_never_signed_for_it) wants each refused and
none signed for it. The passed list gained a.{DOMAIN}, a comment after
the angle address, a quoted local part, and "b@ {other}" - passed by
design (security.md), and the e2e shows it signed for the own domain.
- F13: e2e login_in_other_capitals_does_not_authenticate - AUTH with the
login upper-cased gets 535.
Not changed, written down: V1/F1 - L8 is half closed (refused or passed
still tells an application which domains are hosted here); security.md's
accepted risks and the CHANGELOG say so, and the owner is asked whether to
refuse every foreign address in a display name (changes the 2.4.0 rule).
F11 - deleting a domain rewrites its rows' domain_id (seconds at 1e5 rows),
accepted at this size. F8 (test cannot tell write-first from read-first;
the reviewer verified it with two stores, 30/30), V5, V7, P2: noted.
Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 with the three new cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
8.3 KiB
Go
244 lines
8.3 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Rate-limit scopes (guide § Rate limiting). A level-2 limit is attached
|
|
// either to a domain (counted across all its applications and IPs) or to a
|
|
// single application.
|
|
const (
|
|
RateLimitScopeDomain = "domain"
|
|
RateLimitScopeApp = "application"
|
|
)
|
|
|
|
// Level-2 rate limit operating modes (plan domain-stats-auto-ratelimit).
|
|
const (
|
|
RateLimitModeManual = "manual"
|
|
RateLimitModeAuto = "auto"
|
|
)
|
|
|
|
// Auto rate-limit multiplier bounds shown in the panel.
|
|
const (
|
|
DefaultAutoMultiplier = 2.5
|
|
MinAutoMultiplier = 1.5
|
|
MaxAutoMultiplier = 5.0
|
|
)
|
|
|
|
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
|
|
// a message ceiling over a sliding window, attached to a domain or an
|
|
// application. It is enforced in the journal-milter; level 1 (Postfix anvil,
|
|
// architecture.md § Mail path) is the IP backstop that always applies even
|
|
// when this is absent or the milter is down.
|
|
//
|
|
// Domain and application limits apply once max and window are set. Client IP
|
|
// restriction for an application is stored on the application row, not here
|
|
// (guide § Rate limiting).
|
|
type RateLimit struct {
|
|
Scope string
|
|
RefID int64
|
|
MaxMessages int
|
|
WindowSeconds int
|
|
Mode string // manual | auto
|
|
AutoMultiplier float64 // used when Mode == auto
|
|
AutoUpdatedAt time.Time
|
|
}
|
|
|
|
// Active reports whether the limit is fully configured and should be enforced.
|
|
// IsAuto reports whether the limit derives max_messages from send statistics.
|
|
func (r RateLimit) IsAuto() bool {
|
|
return r.Mode == RateLimitModeAuto
|
|
}
|
|
|
|
func (r RateLimit) Active() bool {
|
|
return r.MaxMessages > 0 && r.WindowSeconds > 0
|
|
}
|
|
|
|
// GetRateLimit loads the level-2 limit configured for a domain or application by
|
|
// its id, for the panel's edit form. ok is false when none is configured.
|
|
func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) {
|
|
row := s.db.QueryRow(
|
|
`SELECT max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
|
|
FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID)
|
|
rl, err := scanRateLimit(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return RateLimit{Scope: scope, RefID: refID}, false, nil
|
|
}
|
|
if err != nil {
|
|
return RateLimit{}, false, fmt.Errorf("get rate limit: %w", err)
|
|
}
|
|
rl.Scope, rl.RefID = scope, refID
|
|
return rl, true, nil
|
|
}
|
|
|
|
// SetRateLimit upserts the level-2 limit for a domain or application. The caller
|
|
// (panel) has already validated the IPs and numbers (security.md); values are
|
|
// stored via bound parameters and read back live by the milter.
|
|
func (s *Store) SetRateLimit(rl RateLimit) error {
|
|
mode := rl.Mode
|
|
if mode == "" {
|
|
mode = RateLimitModeManual
|
|
}
|
|
var autoMult interface{}
|
|
if rl.IsAuto() {
|
|
autoMult = rl.AutoMultiplier
|
|
}
|
|
var autoUpdated interface{}
|
|
if !rl.AutoUpdatedAt.IsZero() {
|
|
autoUpdated = rl.AutoUpdatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO rate_limits (scope, ref_id, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(scope, ref_id) DO UPDATE SET
|
|
max_messages = excluded.max_messages,
|
|
window_seconds = excluded.window_seconds,
|
|
mode = excluded.mode,
|
|
auto_multiplier = excluded.auto_multiplier,
|
|
auto_updated_at = excluded.auto_updated_at`,
|
|
rl.Scope, rl.RefID, rl.MaxMessages, rl.WindowSeconds,
|
|
mode, autoMult, autoUpdated,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("set rate limit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteRateLimit removes the level-2 limit for a domain or application, so the
|
|
// admin can clear it and fall back to level 1 only.
|
|
func (s *Store) DeleteRateLimit(scope string, refID int64) error {
|
|
if _, err := s.db.Exec(`DELETE FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID); err != nil {
|
|
return fmt.Errorf("delete rate limit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteRateLimitsForDomain removes the domain's own limit and the limits of all
|
|
// its applications in one statement. It is called on domain deletion, before the
|
|
// application rows are cascade-deleted, to avoid leaving orphan limit rows.
|
|
func (s *Store) DeleteRateLimitsForDomain(domainID int64) error {
|
|
_, err := s.db.Exec(
|
|
`DELETE FROM rate_limits
|
|
WHERE (scope = 'domain' AND ref_id = ?)
|
|
OR (scope = 'application' AND ref_id IN (SELECT id FROM applications WHERE domain_id = ?))`,
|
|
domainID, domainID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("delete domain rate limits: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RateLimit resolves the level-2 limit that applies to a message, keyed by the
|
|
// human-readable reference the milter has on the receive path: the sending
|
|
// domain name (scope "domain") or the SASL login (scope "application"). ok is
|
|
// false when no limit is configured for that reference.
|
|
func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
|
|
var query string
|
|
switch scope {
|
|
case RateLimitScopeDomain:
|
|
query = `SELECT rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
|
|
FROM rate_limits rl JOIN domains d ON d.id = rl.ref_id
|
|
WHERE rl.scope = 'domain' AND d.name = ?`
|
|
case RateLimitScopeApp:
|
|
query = `SELECT rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
|
|
FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id
|
|
WHERE rl.scope = 'application' AND a.login = ?`
|
|
default:
|
|
return RateLimit{}, false, fmt.Errorf("unknown rate-limit scope %q", scope)
|
|
}
|
|
rl, err := scanRateLimit(s.db.QueryRow(query, ref))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return RateLimit{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return RateLimit{}, false, fmt.Errorf("rate limit for %s %q: %w", scope, ref, err)
|
|
}
|
|
rl.Scope = scope
|
|
return rl, true, nil
|
|
}
|
|
|
|
// CountMessages returns how many distinct messages the reference (a domain
|
|
// name or an application login) has queued since t, for the level-2 sliding
|
|
// window (guide § Rate limiting). It counts distinct queue-ids — one message
|
|
// with many recipients is one message, matching level 1's per-message
|
|
// semantics — and excludes rows that were themselves rejected by a limit (they
|
|
// were never sent). It reuses the send log the journal already writes (README
|
|
// § Rate limiting — the limiter reuses the send log).
|
|
//
|
|
// A domain's messages are the rows of the domain registered under ref now, by
|
|
// id: rows a deleted domain of the same name left behind are not its (audit
|
|
// 2026-10-12, L12).
|
|
func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error) {
|
|
var match string
|
|
switch scope {
|
|
case RateLimitScopeDomain:
|
|
match = domainByName
|
|
case RateLimitScopeApp:
|
|
match = appByLogin
|
|
default:
|
|
return 0, fmt.Errorf("unknown rate-limit scope %q", scope)
|
|
}
|
|
var n int64
|
|
// created_at is stored as RFC3339 UTC, so a lexical comparison against the
|
|
// same format is chronologically correct (as in DeleteSendLogBefore).
|
|
err := s.db.QueryRow(
|
|
`SELECT COUNT(DISTINCT queue_id) FROM send_log
|
|
WHERE `+match+` AND status != ? AND created_at >= ?`,
|
|
ref, StatusRejected, since.UTC().Format(time.RFC3339),
|
|
).Scan(&n)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("count messages for %s %q: %w", scope, ref, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// scanRateLimit reads the stored columns, tolerating NULL numeric columns
|
|
// by leaving the corresponding field zero, which makes the limit inert via
|
|
// Active() until max and window are both set.
|
|
func scanRateLimit(r scanRow) (RateLimit, error) {
|
|
var (
|
|
maxMsgs sql.NullInt64
|
|
windowSecs sql.NullInt64
|
|
mode sql.NullString
|
|
autoMult sql.NullFloat64
|
|
autoUpdated sql.NullString
|
|
)
|
|
if err := r.Scan(&maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
|
|
return RateLimit{}, err
|
|
}
|
|
rl := RateLimit{
|
|
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)
|
|
}
|
|
return rl, nil
|
|
}
|
|
|
|
// splitIPs parses the comma-separated storage form back into a slice, dropping
|
|
// empties so an empty column yields nil (an inactive limit).
|
|
func splitIPs(s string) []string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|