From 00e36df5538325004742c7dae8e401efc5cbd177 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Wed, 12 Aug 2026 23:19:51 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 16 +++ README.md | 2 +- cmd/panel/httpserver.go | 28 ++--- cmd/panel/main.go | 6 + docs/architecture.md | 12 +- docs/guide.md | 29 +++-- internal/app/service.go | 6 +- internal/domain/service.go | 6 +- internal/milter/milter_test.go | 106 ++++++++++++++---- internal/milter/ratelimit.go | 84 +++++++------- internal/store/ratelimits.go | 44 ++++---- internal/store/ratelimits_test.go | 26 +++-- internal/web/handlers/handlers.go | 4 + internal/web/handlers/handlers_account.go | 2 + internal/web/handlers/handlers_apps.go | 4 +- internal/web/handlers/handlers_ratelimit.go | 66 ++++++++++- .../web/handlers/handlers_ratelimit_test.go | 93 +++++++++++++++ .../web/view/templates/domain_detail.html | 39 +++---- internal/web/view/templates/settings.html | 22 ++++ internal/web/view/templates_test.go | 33 +++++- internal/web/web.go | 25 +++-- test/e2e/panel_client.go | 15 ++- 22 files changed, 497 insertions(+), 171 deletions(-) create mode 100644 internal/web/handlers/handlers_ratelimit_test.go 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}}

- + -

The limit counts only connections from these - IPs. Leave empty to leave the limit inactive.

+

Only these IPs use the application ceiling and + skip the domain limit. Other IPs stay under the domain (or + level 1).

- +
@@ -370,7 +374,7 @@ {{if .HasLimit}} + data-confirm="Remove the rate limit for {{.Login}}? The domain limit (or level 1) will apply."> @@ -417,21 +421,18 @@

Optional level-2 sending rate limit

-

Empty IP list = inactive (level-1 only). Status: - {{if .DomainHasRL}}active{{else}}inactive{{end}}.

+

Applies to every client IP on this domain. Status: + {{if .DomainHasRL}}active{{else}}inactive (level 1 only){{end}}. + Level 1 backstop: {{.L1Messages}} messages / {{.L1Window}}s + (Settings). Leave the message limit empty to + use level 1 only.

- - -

The limit counts only connections from these IPs. - Leave empty to leave the limit inactive.

-
- +
diff --git a/internal/web/view/templates/settings.html b/internal/web/view/templates/settings.html index 9153c33..7769665 100644 --- a/internal/web/view/templates/settings.html +++ b/internal/web/view/templates/settings.html @@ -102,4 +102,26 @@ this one stays signed in.

this one stays signed in.

{{end}} + +
+

Sending rate limits

+

Configured in .env / Compose; restart the + container to change level 1. Domain and application ceilings are set on + each domain's page.

+ + +

{{.L1Messages}} messages per {{.L1Window}} + seconds (RATE_LIMIT_MESSAGES_PER_IP / + RATE_LIMIT_WINDOW_SECONDS). Hard ceiling for every connecting IP; + the panel cannot raise a domain or application limit above this.

+ + +

Optional ceiling for all senders on a domain. When + unset, only level 1 applies. Must be ≤ level 1.

+ + +

Optional override: list client IPs and a ceiling + strictly above the domain limit (still ≤ level 1). Those IPs + skip the domain check; everyone else stays under the domain (or level 1).

+
{{end}} diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go index 1aaf43e..4a712a7 100644 --- a/internal/web/view/templates_test.go +++ b/internal/web/view/templates_test.go @@ -241,15 +241,40 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) { if strings.Contains(src, `id="rate-limit"`) { 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 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"`) { t.Error("SPF should sit with DKIM, not with DMARC") } } -// Drill-down pages carry an up-link directly under the heading and above the -// cards. A link at the bottom of a form is easy to miss and drifts from the -// rest of the panel, so the shared back_link template is mandatory on those -// pages and TestDrillDownPagesPlaceBackLinkAboveContent guards its position. +func TestSettingsPageDocumentsRateLimits(t *testing.T) { + body, err := fs.ReadFile(assetsFS, "templates/settings.html") + if err != nil { + 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) { drillDown := map[string]bool{ "user_form.html": true, diff --git a/internal/web/web.go b/internal/web/web.go index f9504fa..466150c 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -70,6 +70,11 @@ type Config struct { // checks must not go through the system resolver — see dnscheck's // externalResolver — so this is how a closed network points them at its own. 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. @@ -96,15 +101,17 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, }, v, setupTokenPath) h := handlers.New(st, domains, apps, handlers.Config{ - Hostname: cfg.Hostname, - SubmissionEnabled: cfg.SubmissionEnabled, - MailLogPath: cfg.MailLogPath, - DataDir: cfg.DataDir, - DBPath: cfg.DBPath, - Version: cfg.Version, - TLSCertFile: cfg.TLSCertFile, - OpenDKIMSocket: cfg.OpenDKIMSocket, - JournalSocket: cfg.JournalSocket, + Hostname: cfg.Hostname, + SubmissionEnabled: cfg.SubmissionEnabled, + MailLogPath: cfg.MailLogPath, + DataDir: cfg.DataDir, + DBPath: cfg.DBPath, + Version: cfg.Version, + TLSCertFile: cfg.TLSCertFile, + OpenDKIMSocket: cfg.OpenDKIMSocket, + JournalSocket: cfg.JournalSocket, + RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP, + RateLimitWindowSeconds: cfg.RateLimitWindowSeconds, }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) return &Server{cfg: cfg, auth: a, handlers: h}, nil } diff --git a/test/e2e/panel_client.go b/test/e2e/panel_client.go index 2b16495..80773f2 100644 --- a/test/e2e/panel_client.go +++ b/test/e2e/panel_client.go @@ -194,15 +194,18 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a return appLogin, password, nil } -// setRateLimit saves a level-2 differentiated limit (guide § Rate limiting) -// on either a domain (/domains/{id}/ratelimit) or an application -// (/applications/{id}/ratelimit). +// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application +// (/applications/{id}/ratelimit). allowedIP is required for the trusted-IP +// override; domain ceilings are posted without IPs. func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error { - resp, body, err := c.postForm(path, url.Values{ - "allowed_ips": {allowedIP}, + vals := url.Values{ "max_messages": {fmt.Sprintf("%d", maxMessages)}, "window_seconds": {fmt.Sprintf("%d", windowSeconds)}, - }) + } + if allowedIP != "" { + vals.Set("allowed_ips", allowedIP) + } + resp, body, err := c.postForm(path, vals) if err != nil { return err }