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