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)
|
||||
}
|
||||
}
|
||||
@@ -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 1:
|
||||
{{$.L1Messages}} / {{$.L1Window}}s —
|
||||
<a href="/settings">Settings</a>).
|
||||
{{if $.DomainHasRL}}Domain ceiling: {{$.DomainRLMaxNum}}.{{else}}No domain ceiling (level 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 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 1 only){{end}}.
|
||||
Level 1 backstop: {{.L1Messages}} messages / {{.L1Window}}s
|
||||
(<a href="/settings">Settings</a>). Leave the message limit empty to
|
||||
use level 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>
|
||||
|
||||
@@ -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 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 1 applies. Must be ≤ level 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 1). Those IPs
|
||||
skip the domain check; everyone else stays under the domain (or level 1).</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+16
-9
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user