diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ed089..abea921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Changed + +- rate limiting (level 2): domain ceilings apply to every client IP (no IP + allowlist). An application ceiling with trusted IPs is an override + **above** the domain limit (still ≤ level 1) and skips the domain check for + those IPs; without IPs the application override is inactive. When no domain + ceiling is set, non-privileged senders use level 1 only. The panel shows the + level-1 backstop on domain/application forms and Settings, rejects maxima + above level 1, and requires an application override to exceed the domain + maximum. Operator guide and architecture updated. + +### Fixed + +- panel: on the domain DNS status grid, the Type (TXT) field height matches + the Host fields. + ## [1.2.3] - 2026-08-12 Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no diff --git a/README.md b/README.md index a32780f..722c294 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ send log and DNS checks in the panel, encrypted backups. - Web panel — domains, applications, deliveries, mail queue, system log, backup - Multi-domain relay — each SASL application is bound to one sending domain - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check -- Two-level rate limiting — IP backstop (Postfix) and per-domain/per-app limits +- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides - Full-server backup and single-domain export/import (optional password encryption) - Single Docker image; data in a `./data` bind mount diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 39bc84e..5c3af6b 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -26,19 +26,21 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef) srvApp, err := web.New(st, domains, apps, web.Config{ - Hostname: cfg.hostname, - CookieSecure: cfg.cookieSecure, - SubmissionEnabled: cfg.submissionEnabled, - MailLogPath: cfg.mailLog, - DataDir: cfg.dataDir, - DBPath: cfg.dbPath, - Version: buildinfo.Version, - TrustedProxyCIDRs: cfg.trustedProxies, - TLSCertFile: cfg.tlsCertFile, - OpenDKIMSocket: cfg.opendkimSocket, - JournalSocket: cfg.journalSocket, - SessionIdleDays: cfg.sessionIdleDays, - DNSResolvers: cfg.dnsResolvers, + Hostname: cfg.hostname, + CookieSecure: cfg.cookieSecure, + SubmissionEnabled: cfg.submissionEnabled, + MailLogPath: cfg.mailLog, + DataDir: cfg.dataDir, + DBPath: cfg.dbPath, + Version: buildinfo.Version, + TrustedProxyCIDRs: cfg.trustedProxies, + TLSCertFile: cfg.tlsCertFile, + OpenDKIMSocket: cfg.opendkimSocket, + JournalSocket: cfg.journalSocket, + SessionIdleDays: cfg.sessionIdleDays, + DNSResolvers: cfg.dnsResolvers, + RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP, + RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, }, cfg.setupTokenPath) if err != nil { return err diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 6484cce..d553466 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -63,6 +63,8 @@ type config struct { 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 @@ -117,6 +119,10 @@ func loadConfig() config { // 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"), diff --git a/docs/architecture.md b/docs/architecture.md index 7d3b663..315fb2a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,11 +81,13 @@ One process, three roles: proxy only. 2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** - (`default_action=accept`) so milter failure does not stop mail. The level-2 - count is the stored send-log rows plus the messages this process has admitted - but not yet written (`internal/milter/inflight.go`), so concurrent sessions - cannot each spend the same last slot; a reservation is released at - end-of-message, on ABORT, or after a 10-minute TTL. + (`default_action=accept`) so milter failure does not stop mail. Domain + ceilings apply to every client IP; an application ceiling with trusted IPs + raises the limit for those IPs only and skips the domain check (guide § Rate + limiting). The level-2 count is the stored send-log rows plus the messages + this process has admitted but not yet written (`internal/milter/inflight.go`), + so concurrent sessions cannot each spend the same last slot; a reservation + is released at end-of-message, on ABORT, or after a 10-minute TTL. 3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by queue-id. Send-log `queued → sent` transitions depend on this goroutine alone (`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)). diff --git a/docs/guide.md b/docs/guide.md index 41a5320..1ae4d37 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -202,7 +202,8 @@ service healthy and will mail be accepted?" shows after manual edits under `/data`. - **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits - (level 2) are configured here. *Export domain* writes a single-domain archive; + (level 2) and trusted-IP application overrides are configured here. + *Export domain* writes a single-domain archive; *Import a domain* on the Backup page reads one back in. - **Deliveries** (`/deliveries`) — searchable send log with server-side filters by domain and application. A row identifies its message and nothing more — @@ -269,8 +270,10 @@ docker compose exec selfpost cat /data/setup-token ## Rate limiting -SelfPost applies two independent limits; both can refuse a submission, but only -level 2 writes a `rejected` row in the send log. +SelfPost applies two independent layers; both can refuse a submission, but only +level 2 writes a `rejected` row in the send log. Level-2 ceilings set in the +panel cannot exceed level 1 (the panel shows the level-1 values and rejects +higher numbers). **Level 1 (IP backstop)** — always on, configured via `.env`: @@ -278,14 +281,20 @@ level 2 writes a `rejected` row in the send log. - `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit` This is an anvil limit per connecting client IP. It keeps working even if the -journal-milter (level 2) is down. +journal-milter (level 2) is down. There is no per-IP bypass. -**Level 2 (per domain / per application)** — optional, configured in the panel -on each domain's page or on an individual application. You set a message -ceiling, a time window, and optionally restrict the limit to specific client -IPs; an empty IP list means the differentiated limit does not apply. When -exceeded, Postfix returns a 4xx and the refusal is recorded in Deliveries as -`rejected`. +**Level 2 — domain** — optional, on each domain's page. A message ceiling and +window for **every** client IP sending as that domain. When unset, only +level 1 applies for non-privileged senders. + +**Level 2 — application (trusted IPs)** — optional override on an application: +list one or more client IPs and a ceiling **strictly above** the domain limit +(still ≤ level 1). Connections from those IPs use the application ceiling and +skip the domain check. Other IPs stay under the domain limit (or level 1 alone). +An application override without trusted IPs is inactive. + +When a level-2 ceiling is exceeded, Postfix returns a 4xx and the refusal is +recorded in Deliveries as `rejected`. ## Backup, restore, and moving a single domain diff --git a/internal/app/service.go b/internal/app/service.go index d9bb886..c4bb392 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -189,9 +189,9 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) { return s.store.GetRateLimit(store.RateLimitScopeApp, appID) } -// SaveRateLimit stores the application-level rate limit. The caller has validated -// the IPs and numbers (security.md); the milter reads the row live, so no reload -// is needed. +// SaveRateLimit stores the application-level trusted-IP override (guide § Rate +// limiting). The caller has validated the IPs and numbers (security.md); the +// milter reads the row live, so no reload is needed. func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error { return s.store.SetRateLimit(store.RateLimit{ Scope: store.RateLimitScopeApp, diff --git a/internal/domain/service.go b/internal/domain/service.go index f0bc64c..8e6a684 100644 --- a/internal/domain/service.go +++ b/internal/domain/service.go @@ -145,13 +145,13 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) { } // SaveRateLimit stores the domain-level rate limit. The caller has validated the -// IPs and numbers (security.md); the milter reads the row live, so no reload is -// needed. +// numbers (security.md); the milter reads the row live, so no reload is needed. +// Domain limits do not use an IP allowlist. func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error { return s.store.SetRateLimit(store.RateLimit{ Scope: store.RateLimitScopeDomain, RefID: domainID, - AllowedIPs: ips, + AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel MaxMessages: maxMessages, WindowSeconds: windowSeconds, }) diff --git a/internal/milter/milter_test.go b/internal/milter/milter_test.go index ac35fa4..d2d2a34 100644 --- a/internal/milter/milter_test.go +++ b/internal/milter/milter_test.go @@ -47,6 +47,9 @@ func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, erro return store.RateLimit{}, false, f.lookupErr } rl, ok := f.limits[scope+"|"+ref] + if ok { + rl.Scope = scope + } return rl, ok, nil } @@ -186,11 +189,14 @@ func TestBracedMacros(t *testing.T) { } } -// limitAt is the client IP the rate-limit tests connect from; the limits below -// register it so the differentiated check applies. +// limitIP is the client IP rate-limit tests connect from. const limitIP = "203.0.113.7" -func activeLimit(ips ...string) store.RateLimit { +func domainLimit() store.RateLimit { + return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600} +} + +func appLimit(ips ...string) store.RateLimit { return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600} } @@ -212,7 +218,7 @@ func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response { func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) { rec := &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP), + store.RateLimitScopeDomain + "|example.com": domainLimit(), }, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max } @@ -227,7 +233,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) { func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) { rec := &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeApp + "|app1": activeLimit(limitIP), + store.RateLimitScopeApp + "|app1": appLimit(limitIP), }, counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max } @@ -239,7 +245,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) { func TestRateLimitAllowsUnderLimit(t *testing.T) { rec := &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP), + store.RateLimitScopeDomain + "|example.com": domainLimit(), }, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max } @@ -251,25 +257,76 @@ func TestRateLimitAllowsUnderLimit(t *testing.T) { } } -func TestRateLimitIgnoresUnregisteredIP(t *testing.T) { +func TestRateLimitDomainAppliesToAnyIP(t *testing.T) { rec := &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": activeLimit("198.51.100.1"), // not limitIP + store.RateLimitScopeDomain + "|example.com": domainLimit(), }, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999}, } - // The sender's IP is not in the domain's registered set, so level-2 does not - // apply even though the count is huge (level-1 anvil would still cover it). + // Domain ceilings apply to every client IP; leftover AllowedIPs on the row + // are ignored. + if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail { + t.Fatalf("domain over limit from any IP = %v, want TempFail", resp) + } +} + +func TestRateLimitTrustedAppSkipsDomain(t *testing.T) { + rec := &fakeRecorder{ + limits: map[string]store.RateLimit{ + store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600}, + store.RateLimitScopeApp + "|app1": { + AllowedIPs: []string{limitIP}, MaxMessages: 10, WindowSeconds: 3600, + }, + }, + counts: map[string]int64{ + store.RateLimitScopeDomain + "|example.com": 5, // over domain + store.RateLimitScopeApp + "|app1": 2, // under app + }, + } if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue { - t.Fatalf("unregistered IP = %v, want Continue (level-2 n/a)", resp) + t.Fatalf("trusted app under its ceiling = %v, want Continue (domain skipped)", resp) + } +} + +func TestRateLimitUnlistedIPHitsDomain(t *testing.T) { + rec := &fakeRecorder{ + limits: map[string]store.RateLimit{ + store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600}, + store.RateLimitScopeApp + "|app1": { + AllowedIPs: []string{"198.51.100.1"}, MaxMessages: 100, WindowSeconds: 3600, + }, + }, + counts: map[string]int64{ + store.RateLimitScopeDomain + "|example.com": 1, + store.RateLimitScopeApp + "|app1": 0, + }, + } + if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail { + t.Fatalf("unlisted IP under domain = %v, want TempFail", resp) + } +} + +func TestRateLimitAppWithoutIPsDoesNotPrivilege(t *testing.T) { + rec := &fakeRecorder{ + limits: map[string]store.RateLimit{ + store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600}, + store.RateLimitScopeApp + "|app1": {MaxMessages: 100, WindowSeconds: 3600}, // no IPs + }, + counts: map[string]int64{ + store.RateLimitScopeDomain + "|example.com": 1, + store.RateLimitScopeApp + "|app1": 0, + }, + } + if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail { + t.Fatalf("app without IPs must not skip domain = %v, want TempFail", resp) } } func TestRateLimitInactiveWithoutCeiling(t *testing.T) { rec := &fakeRecorder{ - // IP registered but no ceiling/window: an inert draft, must not enforce. limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, + store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window }, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999}, } @@ -288,7 +345,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) { func TestRateLimitFailsOpenOnCountError(t *testing.T) { rec := &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP), + store.RateLimitScopeDomain + "|example.com": domainLimit(), }, countErr: errors.New("db down"), } @@ -300,7 +357,7 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) { func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) { rec := &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP), + store.RateLimitScopeDomain + "|example.com": domainLimit(), }, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999}, } @@ -333,7 +390,7 @@ func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) ( func limitedRecorder(count int64) *fakeRecorder { return &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP), + store.RateLimitScopeDomain + "|example.com": domainLimit(), }, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count}, } @@ -395,17 +452,17 @@ func TestReservationReleasedOnAbort(t *testing.T) { } } -// A refused message must not leave the slots it claimed for the limits checked -// before the one that tripped, or every refusal would tighten the ceiling. -func TestRefusalReleasesEarlierReservation(t *testing.T) { +// A trusted app at its ceiling refuses without touching the domain counter; +// no domain reservation should linger after the refusal. +func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) { rec := &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP), - store.RateLimitScopeApp + "|app1": activeLimit(limitIP), + store.RateLimitScopeDomain + "|example.com": domainLimit(), + store.RateLimitScopeApp + "|app1": appLimit(limitIP), }, counts: map[string]int64{ - store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room - store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling + store.RateLimitScopeDomain + "|example.com": 0, + store.RateLimitScopeApp + "|app1": 5, // app at ceiling }, } fl := &inflight{} @@ -415,6 +472,9 @@ func TestRefusalReleasesEarlierReservation(t *testing.T) { if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 { t.Fatalf("domain reservation left behind after refusal: %d", n) } + if n := fl.count(store.RateLimitScopeApp+"|app1", time.Now().Add(-time.Hour)); n != 0 { + t.Fatalf("app reservation left behind after refusal: %d", n) + } } // The in-flight count only covers the limit's own window: a reservation older diff --git a/internal/milter/ratelimit.go b/internal/milter/ratelimit.go index d7a5645..913304b 100644 --- a/internal/milter/ratelimit.go +++ b/internal/milter/ratelimit.go @@ -8,9 +8,11 @@ import ( ) // overLimit reports whether the message currently being received should be -// refused under a level-2 differentiated limit (guide § Rate limiting). It -// checks the domain-level and application-level limits in turn; either being -// exceeded is enough to refuse. +// refused under a level-2 differentiated limit (guide § Rate limiting). +// +// Trusted application IPs (app limit active and client IP listed) use only the +// app ceiling and skip the domain check. Everyone else is under the domain +// ceiling when one is configured; otherwise only level 1 applies. // // It is deliberately fail-open: any store error, or the absence of a usable // limit, is treated as "not over limit" so a malfunction of the level-2 @@ -26,46 +28,48 @@ func (s *session) overLimit() bool { if s.clientIP == "" { return false // no client IP to key on; level-2 does not apply } - checks := []struct{ scope, ref string }{ - {store.RateLimitScopeDomain, domainOf(s.from)}, - {store.RateLimitScopeApp, s.login}, - } - var taken []*reservation - for _, c := range checks { - if c.ref == "" { - continue - } - rl, ok, err := s.rec.RateLimit(c.scope, c.ref) + + if s.login != "" { + rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login) if err != nil { - log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err) - continue + log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err) + } else if ok && rl.Active() && rl.AllowsIP(s.clientIP) { + return s.enforceLimit(store.RateLimitScopeApp, s.login, rl) } - // No limit configured, an inert draft, or a client IP outside the - // registered set: the differentiated limit does not apply here. - if !ok || !rl.Active() || !rl.AllowsIP(s.clientIP) { - continue - } - since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second) - n, err := s.rec.CountMessages(c.scope, c.ref, since) - if err != nil { - log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err) - continue - } - key := c.scope + "|" + c.ref - n += s.flight.count(key, since) - if n >= int64(rl.MaxMessages) { - log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx", - c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP) - // The message is refused, so the slots claimed for the limits - // checked before this one must not stay claimed. - for _, r := range taken { - s.flight.release(r) - } - return true - } - taken = append(taken, s.flight.reserve(key)) } - s.reserved = append(s.reserved, taken...) + + domain := domainOf(s.from) + if domain == "" { + return false + } + rl, ok, err := s.rec.RateLimit(store.RateLimitScopeDomain, domain) + if err != nil { + log.Printf("journal-milter: rate-limit lookup domain %q: %v (fail-open)", domain, err) + return false + } + if !ok || !rl.Active() { + return false + } + return s.enforceLimit(store.RateLimitScopeDomain, domain, rl) +} + +// enforceLimit counts recent messages for scope/ref and refuses when at or +// above the ceiling. On admit it reserves an in-flight slot on the session. +func (s *session) enforceLimit(scope, ref string, rl store.RateLimit) bool { + since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second) + n, err := s.rec.CountMessages(scope, ref, since) + if err != nil { + log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err) + return false + } + key := scope + "|" + ref + n += s.flight.count(key, since) + if n >= int64(rl.MaxMessages) { + log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx", + scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP) + return true + } + s.reserved = append(s.reserved, s.flight.reserve(key)) return false } diff --git a/internal/store/ratelimits.go b/internal/store/ratelimits.go index 0b7c186..8af46ea 100644 --- a/internal/store/ratelimits.go +++ b/internal/store/ratelimits.go @@ -18,36 +18,40 @@ const ( ) // RateLimit is a differentiated level-2 rate limit (guide § Rate limiting): -// an optional set of expected client IPs plus 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. +// 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. // -// Both the IP binding and the ceiling are optional in the schema, but a limit -// is only enforced when it is Active(): the design deliberately allows an -// admin to leave the IP binding empty for apps that send from changing IPs, in -// which case only level 1 protects them (guide § Rate limiting). +// Domain limits apply to every client IP once max and window are set. Application +// limits additionally require AllowedIPs: those trusted addresses get the app +// ceiling (above the domain) and skip the domain check; other IPs stay under +// the domain limit or level 1 alone (guide § Rate limiting). type RateLimit struct { Scope string RefID int64 - AllowedIPs []string // canonical client IPs this limit applies to + AllowedIPs []string // trusted client IPs for an application override MaxMessages int WindowSeconds int } // Active reports whether the limit is fully configured and should be enforced. -// A missing IP binding, ceiling or window leaves the differentiated limit -// inert (guide § Rate limiting): the IP binding is what scopes the limit to a -// known sender. +// Domain: max and window only. Application: also needs at least one trusted IP +// (the privilege that raises the ceiling above the domain). func (r RateLimit) Active() bool { - return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0 + if r.MaxMessages <= 0 || r.WindowSeconds <= 0 { + return false + } + if r.Scope == RateLimitScopeApp { + return len(r.AllowedIPs) > 0 + } + // Domain (and any unset/legacy scope treated as domain-style): no IP list. + return true } -// AllowsIP reports whether ip is one of the limit's registered client IPs. The -// comparison parses both sides so equivalent textual forms of the same address -// match; a client IP outside the list means the differentiated limit does not -// apply to it (level 1 still does). +// AllowsIP reports whether ip is one of the application's trusted client IPs. +// Used only for application overrides; domain limits do not consult this list. +// Equivalent textual forms of the same address match. func (r RateLimit) AllowsIP(ip string) bool { c := net.ParseIP(ip) if c == nil { @@ -183,8 +187,8 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error) } // scanRateLimit reads the three stored columns, tolerating NULL numeric columns -// (an IP-only draft) by leaving the corresponding field zero, which makes the -// limit inert via Active(). +// 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 ( ips sql.NullString diff --git a/internal/store/ratelimits_test.go b/internal/store/ratelimits_test.go index 359fc08..e922145 100644 --- a/internal/store/ratelimits_test.go +++ b/internal/store/ratelimits_test.go @@ -152,27 +152,35 @@ func TestDeleteRateLimitsForDomain(t *testing.T) { func TestRateLimitActiveAndAllowsIP(t *testing.T) { inactive := []RateLimit{ {}, - {AllowedIPs: []string{"203.0.113.1"}}, // no ceiling - {AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window - {MaxMessages: 5, WindowSeconds: 60}, // no IPs + {Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling + {Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window + {Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs + {Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window } for i, rl := range inactive { if rl.Active() { t.Fatalf("case %d: %+v should be inactive", i, rl) } } - active := RateLimit{AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, MaxMessages: 5, WindowSeconds: 60} - if !active.Active() { - t.Fatalf("should be active: %+v", active) + domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60} + if !domainActive.Active() { + t.Fatalf("domain without IPs should be active: %+v", domainActive) } - if !active.AllowsIP("203.0.113.1") || !active.AllowsIP("2001:db8::1") { + appActive := RateLimit{ + Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, + MaxMessages: 5, WindowSeconds: 60, + } + if !appActive.Active() { + t.Fatalf("should be active: %+v", appActive) + } + if !appActive.AllowsIP("203.0.113.1") || !appActive.AllowsIP("2001:db8::1") { t.Fatalf("registered IPs should match") } // Equivalent textual form of the IPv6 address must still match. - if !active.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") { + if !appActive.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") { t.Fatalf("expanded IPv6 form should match") } - if active.AllowsIP("198.51.100.7") || active.AllowsIP("not-an-ip") || active.AllowsIP("") { + if appActive.AllowsIP("198.51.100.7") || appActive.AllowsIP("not-an-ip") || appActive.AllowsIP("") { t.Fatalf("unregistered/invalid IPs must not match") } } diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go index 3e4ec83..bb530d7 100644 --- a/internal/web/handlers/handlers.go +++ b/internal/web/handlers/handlers.go @@ -24,6 +24,10 @@ type Config struct { TLSCertFile string OpenDKIMSocket string JournalSocket string + // Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel + // and used to cap domain/app level-2 ceilings (guide § Rate limiting). + RateLimitMessagesPerIP int + RateLimitWindowSeconds int } // Handlers holds dependencies for authenticated panel routes. diff --git a/internal/web/handlers/handlers_account.go b/internal/web/handlers/handlers_account.go index c359327..dbacd70 100644 --- a/internal/web/handlers/handlers_account.go +++ b/internal/web/handlers/handlers_account.go @@ -59,6 +59,8 @@ func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail) data["Error"] = formErr data["Flash"] = accountFlash(r) + data["L1Messages"] = h.l1Messages() + data["L1Window"] = h.l1Window() h.view.Render(w, status, "settings", data) } diff --git a/internal/web/handlers/handlers_apps.go b/internal/web/handlers/handlers_apps.go index dfe6006..97379a7 100644 --- a/internal/web/handlers/handlers_apps.go +++ b/internal/web/handlers/handlers_apps.go @@ -165,9 +165,11 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st data["ExportErr"] = view.ExportErr data["MinPwLen"] = validate.MinSecretFilePasswordLen data["DomainHasRL"] = domainRLok && domainRL.Active() - data["DomainRLIPs"] = strings.Join(domainRL.AllowedIPs, "\n") data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages) data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds) + data["DomainRLMaxNum"] = domainRL.MaxMessages + data["L1Messages"] = h.l1Messages() + data["L1Window"] = h.l1Window() h.view.Render(w, status, "domain_detail", data) } diff --git a/internal/web/handlers/handlers_ratelimit.go b/internal/web/handlers/handlers_ratelimit.go index e7e9550..fb70123 100644 --- a/internal/web/handlers/handlers_ratelimit.go +++ b/internal/web/handlers/handlers_ratelimit.go @@ -19,24 +19,73 @@ type rateLimitInput struct { windowSeconds int } -func parseRateLimitForm(r *http.Request) (rateLimitInput, error) { +func (h *Handlers) l1Messages() int { + if h.cfg.RateLimitMessagesPerIP > 0 { + return h.cfg.RateLimitMessagesPerIP + } + return 100 +} + +func (h *Handlers) l1Window() int { + if h.cfg.RateLimitWindowSeconds > 0 { + return h.cfg.RateLimitWindowSeconds + } + return defaultRateLimitWindowSeconds +} + +func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) { if err := r.ParseForm(); err != nil { return rateLimitInput{}, fmt.Errorf("invalid form submission") } if r.PostFormValue("clear") != "" { return rateLimitInput{clear: true}, nil } + rawMax := strings.TrimSpace(r.PostFormValue("max_messages")) + if rawMax == "" { + return rateLimitInput{clear: true}, nil + } + maxMessages, err := parsePositiveInt(rawMax, 0) + if err != nil || maxMessages <= 0 { + return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero") + } + if maxMessages > l1Max { + return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max) + } + windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds) + if err != nil || windowSeconds <= 0 { + return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds") + } + return rateLimitInput{maxMessages: maxMessages, windowSeconds: windowSeconds}, nil +} + +func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive bool) (rateLimitInput, error) { + if err := r.ParseForm(); err != nil { + return rateLimitInput{}, fmt.Errorf("invalid form submission") + } + if r.PostFormValue("clear") != "" { + return rateLimitInput{clear: true}, nil + } + rawMax := strings.TrimSpace(r.PostFormValue("max_messages")) + if rawMax == "" { + return rateLimitInput{clear: true}, nil + } ips, err := parseIPList(r.PostFormValue("allowed_ips")) if err != nil { return rateLimitInput{}, err } if len(ips) == 0 { - return rateLimitInput{clear: true}, nil + return rateLimitInput{}, fmt.Errorf("enter at least one trusted client IP for an application override") } - maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0) + maxMessages, err := parsePositiveInt(rawMax, 0) if err != nil || maxMessages <= 0 { return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero") } + if maxMessages > l1Max { + return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max) + } + if domainActive && maxMessages <= domainMax { + return rateLimitInput{}, fmt.Errorf("application override must be greater than the domain limit (%d)", domainMax) + } windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds) if err != nil || windowSeconds <= 0 { return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds") @@ -77,7 +126,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) if !ok { return } - in, err := parseRateLimitForm(r) + in, err := parseDomainRateLimitForm(r, h.l1Messages()) if err != nil { h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, @@ -103,7 +152,14 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } - in, err := parseRateLimitForm(r) + domainRL, domainOK, err := h.domains.RateLimit(d.ID) + if err != nil { + logf("panel: domain %d: rate limit: %v", d.ID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + domainActive := domainOK && domainRL.Active() + in, err := parseAppRateLimitForm(r, h.l1Messages(), domainRL.MaxMessages, domainActive) if err != nil { h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, diff --git a/internal/web/handlers/handlers_ratelimit_test.go b/internal/web/handlers/handlers_ratelimit_test.go new file mode 100644 index 0000000..2cab88b --- /dev/null +++ b/internal/web/handlers/handlers_ratelimit_test.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestParseDomainRateLimitForm(t *testing.T) { + t.Parallel() + form := func(vals url.Values) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return r + } + + in, err := parseDomainRateLimitForm(form(url.Values{ + "max_messages": {"50"}, + "window_seconds": {"3600"}, + }), 100) + if err != nil || in.clear || in.maxMessages != 50 || in.windowSeconds != 3600 || len(in.ips) != 0 { + t.Fatalf("valid domain = %+v err=%v", in, err) + } + + in, err = parseDomainRateLimitForm(form(url.Values{"max_messages": {""}}), 100) + if err != nil || !in.clear { + t.Fatalf("empty max should clear: %+v err=%v", in, err) + } + + _, err = parseDomainRateLimitForm(form(url.Values{ + "max_messages": {"150"}, + "window_seconds": {"3600"}, + }), 100) + if err == nil || !strings.Contains(err.Error(), "level-1") { + t.Fatalf("over L1 want error, got %v", err) + } +} + +func TestParseAppRateLimitForm(t *testing.T) { + t.Parallel() + form := func(vals url.Values) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return r + } + + in, err := parseAppRateLimitForm(form(url.Values{ + "allowed_ips": {"203.0.113.10"}, + "max_messages": {"80"}, + "window_seconds": {"3600"}, + }), 100, 40, true) + if err != nil || in.maxMessages != 80 || len(in.ips) != 1 { + t.Fatalf("valid app override = %+v err=%v", in, err) + } + + _, err = parseAppRateLimitForm(form(url.Values{ + "max_messages": {"80"}, + "window_seconds": {"3600"}, + }), 100, 40, true) + if err == nil || !strings.Contains(err.Error(), "trusted client IP") { + t.Fatalf("missing IPs want error, got %v", err) + } + + _, err = parseAppRateLimitForm(form(url.Values{ + "allowed_ips": {"203.0.113.10"}, + "max_messages": {"40"}, + "window_seconds": {"3600"}, + }), 100, 40, true) + if err == nil || !strings.Contains(err.Error(), "greater than the domain") { + t.Fatalf("app <= domain want error, got %v", err) + } + + _, err = parseAppRateLimitForm(form(url.Values{ + "allowed_ips": {"203.0.113.10"}, + "max_messages": {"150"}, + "window_seconds": {"3600"}, + }), 100, 0, false) + if err == nil || !strings.Contains(err.Error(), "level-1") { + t.Fatalf("over L1 want error, got %v", err) + } + + // No domain limit: any app ceiling ≤ L1 is fine. + in, err = parseAppRateLimitForm(form(url.Values{ + "allowed_ips": {"203.0.113.10"}, + "max_messages": {"50"}, + "window_seconds": {"3600"}, + }), 100, 0, false) + if err != nil || in.maxMessages != 50 { + t.Fatalf("app without domain = %+v err=%v", in, err) + } +} diff --git a/internal/web/view/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html index c44e519..ed3af2e 100644 --- a/internal/web/view/templates/domain_detail.html +++ b/internal/web/view/templates/domain_detail.html @@ -344,19 +344,23 @@
Optional level-2 rate limit
-Caps this application only. The domain level-2 - limit (Domain settings) and the - global level-1 limit still apply.
+Optional trusted-IP override
+Listed client IPs get a higher ceiling than the + domain limit (still capped by level 1: + {{$.L1Messages}} / {{$.L1Window}}s — + Settings). + {{if $.DomainHasRL}}Domain ceiling: {{$.DomainRLMaxNum}}.{{else}}No domain ceiling (level 1 only for other IPs).{{end}}