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
+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)
}
// 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,
+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
// 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,
})
+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
}
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
+44 -40
View File
@@ -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
}
+24 -20
View File
@@ -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
+17 -9
View File
@@ -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")
}
}
+4
View File
@@ -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.
@@ -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)
}
+3 -1
View File
@@ -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)
}
+61 -5
View File
@@ -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,
@@ -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>
</div>
<div class="check-col">
<p class="check-col-title">Optional level-2 rate limit</p>
<p class="muted">Caps this application only. The domain level-2
limit (<a href="#domain-settings">Domain settings</a>) and the
global level-1 limit still apply.</p>
<p class="check-col-title">Optional trusted-IP override</p>
<p class="muted">Listed client IPs get a higher ceiling than the
domain limit (still capped by level&nbsp;1:
{{$.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">
<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>
<p class="muted">The limit counts only connections from these
IPs. Leave empty to leave the limit inactive.</p>
<p class="muted">Only these IPs use the application ceiling and
skip the domain limit. Other IPs stay under the domain (or
level&nbsp;1).</p>
<div class="field-pair">
<div>
<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>
<label>Window (seconds)</label>
@@ -370,7 +374,7 @@
<button type="submit" form="rl-{{.ID}}">Save limit</button>
{{if .HasLimit}}
<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">
<button type="submit" class="danger">Remove limit</button>
</form>
@@ -417,21 +421,18 @@
<div class="check-col">
<p class="check-col-title">Optional level-2 sending rate limit</p>
<p class="muted">Empty IP list = inactive (level-1 only). Status:
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive{{end}}.</p>
<p class="muted">Applies to every client IP on this domain. Status:
{{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">
<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>
<label for="d_max">Message limit</label>
<input id="d_max" name="max_messages" type="number" min="1"
value="{{.DomainRLMax}}" placeholder="1000">
<input id="d_max" name="max_messages" type="number" min="1" max="{{.L1Messages}}"
value="{{.DomainRLMax}}" placeholder="{{.L1Messages}}">
</div>
<div>
<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>
</div>
{{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}}
+29 -4
View File
@@ -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&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"`) {
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,
+16 -9
View File
@@ -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
}