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:
@@ -12,6 +12,7 @@
|
||||
<a class="back" href="/">← All domains</a>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
||||
|
||||
{{if .NewCred}}
|
||||
<div class="card credential">
|
||||
@@ -79,6 +80,25 @@
|
||||
<button type="submit">Save mode</button>
|
||||
</form>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Rate limit{{if .HasLimit}} (active){{end}}</summary>
|
||||
<form method="post" action="/applications/{{.ID}}/ratelimit">
|
||||
<label>Expected client IPs (one per line or comma-separated)</label>
|
||||
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
|
||||
<label>Message limit</label>
|
||||
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
|
||||
<label>Window (seconds)</label>
|
||||
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
|
||||
<button type="submit">Save limit</button>
|
||||
</form>
|
||||
{{if .HasLimit}}
|
||||
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
|
||||
onsubmit="return confirm('Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.')">
|
||||
<input type="hidden" name="clear" value="1">
|
||||
<button type="submit" class="danger">Remove limit</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</details>
|
||||
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
||||
onsubmit="return confirm('Regenerate the password for {{.Login}}? The current password stops working immediately.')">
|
||||
<button type="submit">New password</button>
|
||||
@@ -97,6 +117,41 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sending rate limit (domain)</h2>
|
||||
<p class="muted">Optional level-2 limit (spec 7.4): cap how many messages this
|
||||
domain may send from its expected client IP(s) within a time window, summed
|
||||
across all its applications. It counts messages — one message to many
|
||||
recipients counts once. Leave the IP list empty to disable it and rely only on
|
||||
the global level-1 limit. Applications that send from changing IPs should be
|
||||
left unbound here.</p>
|
||||
|
||||
<p class="muted">Status:
|
||||
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</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>
|
||||
|
||||
<label for="d_max">Message limit</label>
|
||||
<input id="d_max" name="max_messages" type="number" min="1"
|
||||
value="{{.DomainRLMax}}" placeholder="1000">
|
||||
|
||||
<label for="d_win">Window (seconds)</label>
|
||||
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
||||
|
||||
<button type="submit">Save limit</button>
|
||||
</form>
|
||||
{{if .DomainHasRL}}
|
||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
|
||||
onsubmit="return confirm('Remove the domain rate limit? Only the global level-1 limit will apply.')">
|
||||
<input type="hidden" name="clear" value="1">
|
||||
<button type="submit" class="danger">Remove limit</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Add an application</h2>
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
||||
|
||||
Reference in New Issue
Block a user