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:
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user