rate limit: domain ceiling for all IPs, trusted app override

Invert level-2 semantics so domain limits apply to every client IP and
application limits with trusted IPs raise the ceiling above the domain
(still capped by level 1). Panel shows L1, validates maxima, and documents
the model on Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-12 23:19:51 +03:00
parent b0ebe061b5
commit 00e36df553
22 changed files with 497 additions and 171 deletions
+16
View File
@@ -5,6 +5,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [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 ## [1.2.3] - 2026-08-12
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no
+1 -1
View File
@@ -24,7 +24,7 @@ send log and DNS checks in the panel, encrypted backups.
- Web panel — domains, applications, deliveries, mail queue, system log, backup - Web panel — domains, applications, deliveries, mail queue, system log, backup
- Multi-domain relay — each SASL application is bound to one sending domain - Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - 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) - Full-server backup and single-domain export/import (optional password encryption)
- Single Docker image; data in a `./data` bind mount - Single Docker image; data in a `./data` bind mount
+2
View File
@@ -39,6 +39,8 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
JournalSocket: cfg.journalSocket, JournalSocket: cfg.journalSocket,
SessionIdleDays: cfg.sessionIdleDays, SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers, DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
if err != nil { if err != nil {
return err return err
+6
View File
@@ -63,6 +63,8 @@ type config struct {
trustedProxies []*net.IPNet trustedProxies []*net.IPNet
sessionIdleDays int sessionIdleDays int
dnsResolvers []string dnsResolvers []string
rateLimitMessagesPerIP int
rateLimitWindowSeconds int
// Read-only inputs to the panel's status page: the certificate Postfix // Read-only inputs to the panel's status page: the certificate Postfix
// serves and the two milter sockets it connects to. The defaults mirror // 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. // means dnscheck's public defaults; a closed network names its own here.
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")), 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"), tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"), opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
+7 -5
View File
@@ -81,11 +81,13 @@ One process, three roles:
proxy only. proxy only.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ 2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** 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 (`default_action=accept`) so milter failure does not stop mail. Domain
count is the stored send-log rows plus the messages this process has admitted ceilings apply to every client IP; an application ceiling with trusted IPs
but not yet written (`internal/milter/inflight.go`), so concurrent sessions raises the limit for those IPs only and skips the domain check (guide § Rate
cannot each spend the same last slot; a reservation is released at limiting). The level-2 count is the stored send-log rows plus the messages
end-of-message, on ABORT, or after a 10-minute TTL. 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 3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)). (`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
+19 -10
View File
@@ -202,7 +202,8 @@ service healthy and will mail be accepted?"
shows after manual edits under `/data`. shows after manual edits under `/data`.
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM - **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits 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. *Import a domain* on the Backup page reads one back in.
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters - **Deliveries** (`/deliveries`) — searchable send log with server-side filters
by domain and application. A row identifies its message and nothing more — 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 ## Rate limiting
SelfPost applies two independent limits; both can refuse a submission, but only SelfPost applies two independent layers; both can refuse a submission, but only
level 2 writes a `rejected` row in the send log. 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`: **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` - `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
This is an anvil limit per connecting client IP. It keeps working even if the 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 **Level 2 domain** — optional, on each domain's page. A message ceiling and
on each domain's page or on an individual application. You set a message window for **every** client IP sending as that domain. When unset, only
ceiling, a time window, and optionally restrict the limit to specific client level 1 applies for non-privileged senders.
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 **Level 2 — application (trusted IPs)** — optional override on an application:
`rejected`. 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 ## Backup, restore, and moving a single domain
+3 -3
View File
@@ -189,9 +189,9 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeApp, appID) return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
} }
// SaveRateLimit stores the application-level rate limit. The caller has validated // SaveRateLimit stores the application-level trusted-IP override (guide § Rate
// the IPs and numbers (security.md); the milter reads the row live, so no reload // limiting). The caller has validated the IPs and numbers (security.md); the
// is needed. // milter reads the row live, so no reload is needed.
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error { func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{ return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeApp, Scope: store.RateLimitScopeApp,
+3 -3
View File
@@ -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 // 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 // numbers (security.md); the milter reads the row live, so no reload is needed.
// needed. // Domain limits do not use an IP allowlist.
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error { func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{ return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeDomain, Scope: store.RateLimitScopeDomain,
RefID: domainID, RefID: domainID,
AllowedIPs: ips, AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel
MaxMessages: maxMessages, MaxMessages: maxMessages,
WindowSeconds: windowSeconds, WindowSeconds: windowSeconds,
}) })
+83 -23
View File
@@ -47,6 +47,9 @@ func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, erro
return store.RateLimit{}, false, f.lookupErr return store.RateLimit{}, false, f.lookupErr
} }
rl, ok := f.limits[scope+"|"+ref] rl, ok := f.limits[scope+"|"+ref]
if ok {
rl.Scope = scope
}
return rl, ok, nil 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 // limitIP is the client IP rate-limit tests connect from.
// register it so the differentiated check applies.
const limitIP = "203.0.113.7" 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} 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) { func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ 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 counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
} }
@@ -227,7 +233,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) { func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ 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 counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
} }
@@ -239,7 +245,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
func TestRateLimitAllowsUnderLimit(t *testing.T) { func TestRateLimitAllowsUnderLimit(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ 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 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{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ 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}, 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 // Domain ceilings apply to every client IP; leftover AllowedIPs on the row
// apply even though the count is huge (level-1 anvil would still cover it). // 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 { 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) { func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
// IP registered but no ceiling/window: an inert draft, must not enforce.
limits: map[string]store.RateLimit{ 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}, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
} }
@@ -288,7 +345,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
func TestRateLimitFailsOpenOnCountError(t *testing.T) { func TestRateLimitFailsOpenOnCountError(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP), store.RateLimitScopeDomain + "|example.com": domainLimit(),
}, },
countErr: errors.New("db down"), countErr: errors.New("db down"),
} }
@@ -300,7 +357,7 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) {
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) { func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ 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}, 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 { func limitedRecorder(count int64) *fakeRecorder {
return &fakeRecorder{ return &fakeRecorder{
limits: map[string]store.RateLimit{ 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}, 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 // A trusted app at its ceiling refuses without touching the domain counter;
// before the one that tripped, or every refusal would tighten the ceiling. // no domain reservation should linger after the refusal.
func TestRefusalReleasesEarlierReservation(t *testing.T) { func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP), store.RateLimitScopeDomain + "|example.com": domainLimit(),
store.RateLimitScopeApp + "|app1": activeLimit(limitIP), store.RateLimitScopeApp + "|app1": appLimit(limitIP),
}, },
counts: map[string]int64{ counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room store.RateLimitScopeDomain + "|example.com": 0,
store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling store.RateLimitScopeApp + "|app1": 5, // app at ceiling
}, },
} }
fl := &inflight{} 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 { 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) 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 // The in-flight count only covers the limit's own window: a reservation older
+36 -32
View File
@@ -8,9 +8,11 @@ import (
) )
// overLimit reports whether the message currently being received should be // overLimit reports whether the message currently being received should be
// refused under a level-2 differentiated limit (guide § Rate limiting). It // refused under a level-2 differentiated limit (guide § Rate limiting).
// checks the domain-level and application-level limits in turn; either being //
// exceeded is enough to refuse. // 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 // 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 // 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 == "" { if s.clientIP == "" {
return false // no client IP to key on; level-2 does not apply return false // no client IP to key on; level-2 does not apply
} }
checks := []struct{ scope, ref string }{
{store.RateLimitScopeDomain, domainOf(s.from)}, if s.login != "" {
{store.RateLimitScopeApp, s.login}, rl, ok, err := s.rec.RateLimit(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 err != nil { if err != nil {
log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err) log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
continue } 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
} }
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) since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
n, err := s.rec.CountMessages(c.scope, c.ref, since) n, err := s.rec.CountMessages(scope, ref, since)
if err != nil { if err != nil {
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err) log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err)
continue return false
} }
key := c.scope + "|" + c.ref key := scope + "|" + ref
n += s.flight.count(key, since) n += s.flight.count(key, since)
if n >= int64(rl.MaxMessages) { if n >= int64(rl.MaxMessages) {
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx", 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) scope, 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 return true
} }
taken = append(taken, s.flight.reserve(key)) s.reserved = append(s.reserved, s.flight.reserve(key))
}
s.reserved = append(s.reserved, taken...)
return false return false
} }
+24 -20
View File
@@ -18,36 +18,40 @@ const (
) )
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting): // 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 // a message ceiling over a sliding window, attached to a domain or an
// window, attached to a domain or an application. It is enforced in the // application. It is enforced in the journal-milter; level 1 (Postfix anvil,
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the // architecture.md § Mail path) is the IP backstop that always applies even
// IP backstop that always applies even when this is absent or the milter is // when this is absent or the milter is down.
// down.
// //
// Both the IP binding and the ceiling are optional in the schema, but a limit // Domain limits apply to every client IP once max and window are set. Application
// is only enforced when it is Active(): the design deliberately allows an // limits additionally require AllowedIPs: those trusted addresses get the app
// admin to leave the IP binding empty for apps that send from changing IPs, in // ceiling (above the domain) and skip the domain check; other IPs stay under
// which case only level 1 protects them (guide § Rate limiting). // the domain limit or level 1 alone (guide § Rate limiting).
type RateLimit struct { type RateLimit struct {
Scope string Scope string
RefID int64 RefID int64
AllowedIPs []string // canonical client IPs this limit applies to AllowedIPs []string // trusted client IPs for an application override
MaxMessages int MaxMessages int
WindowSeconds int WindowSeconds int
} }
// Active reports whether the limit is fully configured and should be enforced. // Active reports whether the limit is fully configured and should be enforced.
// A missing IP binding, ceiling or window leaves the differentiated limit // Domain: max and window only. Application: also needs at least one trusted IP
// inert (guide § Rate limiting): the IP binding is what scopes the limit to a // (the privilege that raises the ceiling above the domain).
// known sender.
func (r RateLimit) Active() bool { 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 // AllowsIP reports whether ip is one of the application's trusted client IPs.
// comparison parses both sides so equivalent textual forms of the same address // Used only for application overrides; domain limits do not consult this list.
// match; a client IP outside the list means the differentiated limit does not // Equivalent textual forms of the same address match.
// apply to it (level 1 still does).
func (r RateLimit) AllowsIP(ip string) bool { func (r RateLimit) AllowsIP(ip string) bool {
c := net.ParseIP(ip) c := net.ParseIP(ip)
if c == nil { 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 // scanRateLimit reads the three stored columns, tolerating NULL numeric columns
// (an IP-only draft) by leaving the corresponding field zero, which makes the // by leaving the corresponding field zero, which makes the limit inert via
// limit inert via Active(). // Active() until max and window are both set.
func scanRateLimit(r scanRow) (RateLimit, error) { func scanRateLimit(r scanRow) (RateLimit, error) {
var ( var (
ips sql.NullString ips sql.NullString
+17 -9
View File
@@ -152,27 +152,35 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
func TestRateLimitActiveAndAllowsIP(t *testing.T) { func TestRateLimitActiveAndAllowsIP(t *testing.T) {
inactive := []RateLimit{ inactive := []RateLimit{
{}, {},
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling {Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window {Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{MaxMessages: 5, WindowSeconds: 60}, // no IPs {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 { for i, rl := range inactive {
if rl.Active() { if rl.Active() {
t.Fatalf("case %d: %+v should be inactive", i, rl) 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} domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60}
if !active.Active() { if !domainActive.Active() {
t.Fatalf("should be active: %+v", 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") t.Fatalf("registered IPs should match")
} }
// Equivalent textual form of the IPv6 address must still 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") 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") t.Fatalf("unregistered/invalid IPs must not match")
} }
} }
+4
View File
@@ -24,6 +24,10 @@ type Config struct {
TLSCertFile string TLSCertFile string
OpenDKIMSocket string OpenDKIMSocket string
JournalSocket 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. // Handlers holds dependencies for authenticated panel routes.
@@ -59,6 +59,8 @@ func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail) data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
data["Error"] = formErr data["Error"] = formErr
data["Flash"] = accountFlash(r) data["Flash"] = accountFlash(r)
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
h.view.Render(w, status, "settings", data) h.view.Render(w, status, "settings", data)
} }
+3 -1
View File
@@ -165,9 +165,11 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
data["ExportErr"] = view.ExportErr data["ExportErr"] = view.ExportErr
data["MinPwLen"] = validate.MinSecretFilePasswordLen data["MinPwLen"] = validate.MinSecretFilePasswordLen
data["DomainHasRL"] = domainRLok && domainRL.Active() data["DomainHasRL"] = domainRLok && domainRL.Active()
data["DomainRLIPs"] = strings.Join(domainRL.AllowedIPs, "\n")
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages) data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds) 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) h.view.Render(w, status, "domain_detail", data)
} }
+61 -5
View File
@@ -19,24 +19,73 @@ type rateLimitInput struct {
windowSeconds int 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 { if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission") return rateLimitInput{}, fmt.Errorf("invalid form submission")
} }
if r.PostFormValue("clear") != "" { if r.PostFormValue("clear") != "" {
return rateLimitInput{clear: true}, nil 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")) ips, err := parseIPList(r.PostFormValue("allowed_ips"))
if err != nil { if err != nil {
return rateLimitInput{}, err return rateLimitInput{}, err
} }
if len(ips) == 0 { 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 { if err != nil || maxMessages <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero") 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) windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
if err != nil || windowSeconds <= 0 { if err != nil || windowSeconds <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds") 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 { if !ok {
return return
} }
in, err := parseRateLimitForm(r) in, err := parseDomainRateLimitForm(r, h.l1Messages())
if err != nil { if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
@@ -103,7 +152,14 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return 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 { if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
@@ -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)
}
}
+20 -19
View File
@@ -344,19 +344,23 @@
</form> </form>
</div> </div>
<div class="check-col"> <div class="check-col">
<p class="check-col-title">Optional level-2 rate limit</p> <p class="check-col-title">Optional trusted-IP override</p>
<p class="muted">Caps this application only. The domain level-2 <p class="muted">Listed client IPs get a higher ceiling than the
limit (<a href="#domain-settings">Domain settings</a>) and the domain limit (still capped by level&nbsp;1:
global level-1 limit still apply.</p> {{$.L1Messages}} / {{$.L1Window}}s —
<a href="/settings">Settings</a>).
{{if $.DomainHasRL}}Domain ceiling: {{$.DomainRLMaxNum}}.{{else}}No domain ceiling (level&nbsp;1 only for other IPs).{{end}}</p>
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit"> <form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<label>Expected client IPs (one per line or comma-separated)</label> <label>Trusted client IPs (required; one per line or comma-separated)</label>
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea> <textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
<p class="muted">The limit counts only connections from these <p class="muted">Only these IPs use the application ceiling and
IPs. Leave empty to leave the limit inactive.</p> skip the domain limit. Other IPs stay under the domain (or
level&nbsp;1).</p>
<div class="field-pair"> <div class="field-pair">
<div> <div>
<label>Message limit</label> <label>Message limit</label>
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500"> <input name="max_messages" type="number" min="1" max="{{$.L1Messages}}"
value="{{.MaxText}}" placeholder="{{$.L1Messages}}">
</div> </div>
<div> <div>
<label>Window (seconds)</label> <label>Window (seconds)</label>
@@ -370,7 +374,7 @@
<button type="submit" form="rl-{{.ID}}">Save limit</button> <button type="submit" form="rl-{{.ID}}">Save limit</button>
{{if .HasLimit}} {{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit" <form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply."> data-confirm="Remove the rate limit for {{.Login}}? The domain limit (or level 1) will apply.">
<input type="hidden" name="clear" value="1"> <input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button> <button type="submit" class="danger">Remove limit</button>
</form> </form>
@@ -417,21 +421,18 @@
<div class="check-col"> <div class="check-col">
<p class="check-col-title">Optional level-2 sending rate limit</p> <p class="check-col-title">Optional level-2 sending rate limit</p>
<p class="muted">Empty IP list = inactive (level-1 only). Status: <p class="muted">Applies to every client IP on this domain. Status:
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive{{end}}.</p> {{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level&nbsp;1 only){{end}}.
Level&nbsp;1 backstop: {{.L1Messages}} messages / {{.L1Window}}s
(<a href="/settings">Settings</a>). Leave the message limit empty to
use level&nbsp;1 only.</p>
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit"> <form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
<textarea id="d_ips" name="allowed_ips" rows="2"
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
<p class="muted">The limit counts only connections from these IPs.
Leave empty to leave the limit inactive.</p>
<div class="field-pair"> <div class="field-pair">
<div> <div>
<label for="d_max">Message limit</label> <label for="d_max">Message limit</label>
<input id="d_max" name="max_messages" type="number" min="1" <input id="d_max" name="max_messages" type="number" min="1" max="{{.L1Messages}}"
value="{{.DomainRLMax}}" placeholder="1000"> value="{{.DomainRLMax}}" placeholder="{{.L1Messages}}">
</div> </div>
<div> <div>
<label for="d_win">Window (seconds)</label> <label for="d_win">Window (seconds)</label>
+22
View File
@@ -102,4 +102,26 @@ this one stays signed in.</p>
this one stays signed in.</p> this one stays signed in.</p>
</div> </div>
{{end}} {{end}}
<div class="card" id="rate-limits">
<h2>Sending rate limits</h2>
<p class="muted">Configured in <code>.env</code> / Compose; restart the
container to change level&nbsp;1. Domain and application ceilings are set on
each domain's page.</p>
<label>Level 1 — per client IP (Postfix)</label>
<p><strong>{{.L1Messages}}</strong> messages per <strong>{{.L1Window}}</strong>
seconds (<code>RATE_LIMIT_MESSAGES_PER_IP</code> /
<code>RATE_LIMIT_WINDOW_SECONDS</code>). Hard ceiling for every connecting IP;
the panel cannot raise a domain or application limit above this.</p>
<label>Level 2 — domain</label>
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When
unset, only level&nbsp;1 applies. Must be ≤ level&nbsp;1.</p>
<label>Level 2 — application (trusted IPs)</label>
<p class="muted">Optional override: list client IPs and a ceiling
<em>strictly above</em> the domain limit (still ≤ level&nbsp;1). Those IPs
skip the domain check; everyone else stays under the domain (or level&nbsp;1).</p>
</div>
{{end}} {{end}}
+29 -4
View File
@@ -241,15 +241,40 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
if strings.Contains(src, `id="rate-limit"`) { if strings.Contains(src, `id="rate-limit"`) {
t.Error("domain rate limit should live inside domain-settings, not its own card") t.Error("domain rate limit should live inside domain-settings, not its own card")
} }
if strings.Contains(src, `id="d_ips"`) {
t.Error("domain rate limit must not ask for client IPs")
}
if !strings.Contains(src, "level&nbsp;1") {
t.Error("domain rate limit should mention the level-1 backstop")
}
if !strings.Contains(src, "Trusted client IPs") {
t.Error("application override should ask for trusted client IPs")
}
if strings.Contains(src, `id="spf-dmarc"`) { if strings.Contains(src, `id="spf-dmarc"`) {
t.Error("SPF should sit with DKIM, not with DMARC") t.Error("SPF should sit with DKIM, not with DMARC")
} }
} }
// Drill-down pages carry an up-link directly under the heading and above the func TestSettingsPageDocumentsRateLimits(t *testing.T) {
// cards. A link at the bottom of a form is easy to miss and drifts from the body, err := fs.ReadFile(assetsFS, "templates/settings.html")
// rest of the panel, so the shared back_link template is mandatory on those if err != nil {
// pages and TestDrillDownPagesPlaceBackLinkAboveContent guards its position. t.Fatalf("read settings: %v", err)
}
src := string(body)
if !strings.Contains(src, `id="rate-limits"`) {
t.Error("settings should include a sending rate limits card")
}
for _, want := range []string{
"RATE_LIMIT_MESSAGES_PER_IP",
"Level 2 — domain",
"trusted IPs",
} {
if !strings.Contains(src, want) {
t.Errorf("settings rate limits card missing %q", want)
}
}
}
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) { func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
drillDown := map[string]bool{ drillDown := map[string]bool{
"user_form.html": true, "user_form.html": true,
+7
View File
@@ -70,6 +70,11 @@ type Config struct {
// checks must not go through the system resolver — see dnscheck's // checks must not go through the system resolver — see dnscheck's
// externalResolver — so this is how a closed network points them at its own. // externalResolver — so this is how a closed network points them at its own.
DNSResolvers []string DNSResolvers []string
// RateLimitMessagesPerIP and RateLimitWindowSeconds are the level-1
// Postfix anvil backstop (env RATE_LIMIT_*), mirrored into the panel for
// display and to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int
RateLimitWindowSeconds int
} }
// Server is the panel HTTP application. // Server is the panel HTTP application.
@@ -105,6 +110,8 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
TLSCertFile: cfg.TLSCertFile, TLSCertFile: cfg.TLSCertFile,
OpenDKIMSocket: cfg.OpenDKIMSocket, OpenDKIMSocket: cfg.OpenDKIMSocket,
JournalSocket: cfg.JournalSocket, JournalSocket: cfg.JournalSocket,
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
return &Server{cfg: cfg, auth: a, handlers: h}, nil return &Server{cfg: cfg, auth: a, handlers: h}, nil
} }
+9 -6
View File
@@ -194,15 +194,18 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a
return appLogin, password, nil return appLogin, password, nil
} }
// setRateLimit saves a level-2 differentiated limit (guide § Rate limiting) // setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
// on either a domain (/domains/{id}/ratelimit) or an application // (/applications/{id}/ratelimit). allowedIP is required for the trusted-IP
// (/applications/{id}/ratelimit). // override; domain ceilings are posted without IPs.
func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error { func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error {
resp, body, err := c.postForm(path, url.Values{ vals := url.Values{
"allowed_ips": {allowedIP},
"max_messages": {fmt.Sprintf("%d", maxMessages)}, "max_messages": {fmt.Sprintf("%d", maxMessages)},
"window_seconds": {fmt.Sprintf("%d", windowSeconds)}, "window_seconds": {fmt.Sprintf("%d", windowSeconds)},
}) }
if allowedIP != "" {
vals.Set("allowed_ips", allowedIP)
}
resp, body, err := c.postForm(path, vals)
if err != nil { if err != nil {
return err return err
} }