Phase 8: level-2 differentiated rate limits (spec 7.4)

The journal-milter, until now a pure monitor, now refuses a message with a
4xx tempfail (RespTempFail/451) at MAIL FROM when a per-domain or per-
application limit is exceeded. Key is the client IP; the count is
COUNT(DISTINCT queue_id) over a sliding window reusing the send log; the
limit applies only when a non-empty IP binding matches the client (empty
binding => level-1 only, per spec 7.4). Enforcement is fail-open on the
milter's own errors — a limiter malfunction never blocks mail, and Postfix's
level-1 anvil limit stays the independent backstop. Refused messages are
recorded in send_log with status "rejected" for UI visibility.

- store/ratelimits.go: RateLimit type (+Active/AllowsIP), id-keyed get/set/
  delete for the panel, name/login-keyed lookup + windowed distinct-message
  count for the milter, DeleteRateLimitsForDomain. No migration — the
  rate_limits table has existed since Phase 2.
- milter: enforce at MailFrom, fail-open helper overLimit, InsertRejected.
- web: server-side validated IP/ceiling/window forms on the domain page and
  per application; routes POST /domains/{id}/ratelimit and
  /applications/{aid}/ratelimit. Milter reads rows live, so no reload.
- domain/app services clear limits on deletion (rate_limits has no FK cascade).

Unit tests + container e2e (p8) green: refusal on both scopes, unregistered
IP ignored, fail-open with the panel stopped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:57:38 +03:00
parent 9d4942aef6
commit 223f3cdc42
13 changed files with 1030 additions and 24 deletions
+76 -13
View File
@@ -27,6 +27,20 @@ type detailView struct {
FormMode string
FormAddrs string
NewCred *newCred
// RateLimitErr surfaces a validation error from a domain- or application-level
// rate-limit form (spec 7.4) as a page banner.
RateLimitErr string
}
// appRateLimitView pairs an application with its differentiated rate-limit
// settings for the domain page. store.Application is embedded so the existing
// template fields (Login, AddressMode, Addresses, ID) resolve unchanged.
type appRateLimitView struct {
store.Application
HasLimit bool // an active limit is configured
IPsText string // allowed IPs, newline-joined for the textarea
MaxText string // message ceiling, blank when unset
WindowVal string // window seconds, defaulted when unset
}
// handleDomainDetail shows a single domain: its DKIM DNS record (spec 7.2.10)
@@ -56,23 +70,70 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
appViews := make([]appRateLimitView, 0, len(apps))
for _, a := range apps {
rl, ok, err := s.apps.RateLimit(a.ID)
if err != nil {
logf("panel: application %d: rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
appViews = append(appViews, appRateLimitView{
Application: a,
HasLimit: ok && rl.Active(),
IPsText: strings.Join(rl.AllowedIPs, "\n"),
MaxText: intOrBlank(rl.MaxMessages),
WindowVal: windowOrDefault(rl.WindowSeconds),
})
}
domainRL, domainRLok, err := s.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
}
s.render(w, status, "domain_detail", map[string]any{
"Title": "SelfPost — " + d.Name,
"User": currentUser(r),
"Domain": d,
"Record": record,
"Apps": apps,
"Error": view.FormErr,
"FormLogin": view.FormLogin,
"FormMode": view.FormMode,
"FormAddrs": view.FormAddrs,
"NewCred": view.NewCred,
"Flash": detailFlash(r),
"Wildcard": store.AddressModeWildcard,
"List": store.AddressModeList,
"Title": "SelfPost — " + d.Name,
"User": currentUser(r),
"Domain": d,
"Record": record,
"Apps": appViews,
"Error": view.FormErr,
"FormLogin": view.FormLogin,
"FormMode": view.FormMode,
"FormAddrs": view.FormAddrs,
"NewCred": view.NewCred,
"Flash": detailFlash(r),
"Wildcard": store.AddressModeWildcard,
"List": store.AddressModeList,
"RateLimitErr": view.RateLimitErr,
"DomainHasRL": domainRLok && domainRL.Active(),
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
})
}
// intOrBlank renders a non-positive number as an empty string so an unset field
// shows blank rather than "0".
func intOrBlank(n int) string {
if n <= 0 {
return ""
}
return strconv.Itoa(n)
}
// windowOrDefault renders the window seconds, substituting the default when
// unset so the form always suggests a sensible value.
func windowOrDefault(n int) string {
if n <= 0 {
return strconv.Itoa(defaultRateLimitWindowSeconds)
}
return strconv.Itoa(n)
}
// detailFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func detailFlash(r *http.Request) string {
@@ -81,6 +142,8 @@ func detailFlash(r *http.Request) string {
return "Application deleted."
case r.URL.Query().Get("modeupdated") != "":
return "Application address mode updated."
case r.URL.Query().Get("ratelimit") != "":
return "Rate limit updated."
default:
return ""
}