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:
@@ -5,6 +5,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- rate limiting (level 2): domain ceilings apply to every client IP (no IP
|
||||||
|
allowlist). An application ceiling with trusted IPs is an override
|
||||||
|
**above** the domain limit (still ≤ level 1) and skips the domain check for
|
||||||
|
those IPs; without IPs the application override is inactive. When no domain
|
||||||
|
ceiling is set, non-privileged senders use level 1 only. The panel shows the
|
||||||
|
level-1 backstop on domain/application forms and Settings, rejects maxima
|
||||||
|
above level 1, and requires an application override to exceed the domain
|
||||||
|
maximum. Operator guide and architecture updated.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- panel: on the domain DNS status grid, the Type (TXT) field height matches
|
||||||
|
the Host fields.
|
||||||
|
|
||||||
## [1.2.3] - 2026-08-12
|
## [1.2.3] - 2026-08-12
|
||||||
|
|
||||||
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no
|
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
||||||
- Multi-domain relay — each SASL application is bound to one sending domain
|
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
||||||
- Two-level rate limiting — IP backstop (Postfix) and per-domain/per-app limits
|
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
|
||||||
- Full-server backup and single-domain export/import (optional password encryption)
|
- Full-server backup and single-domain export/import (optional password encryption)
|
||||||
- Single Docker image; data in a `./data` bind mount
|
- Single Docker image; data in a `./data` bind mount
|
||||||
|
|
||||||
|
|||||||
+15
-13
@@ -26,19 +26,21 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
|||||||
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
|
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
|
||||||
|
|
||||||
srvApp, err := web.New(st, domains, apps, web.Config{
|
srvApp, err := web.New(st, domains, apps, web.Config{
|
||||||
Hostname: cfg.hostname,
|
Hostname: cfg.hostname,
|
||||||
CookieSecure: cfg.cookieSecure,
|
CookieSecure: cfg.cookieSecure,
|
||||||
SubmissionEnabled: cfg.submissionEnabled,
|
SubmissionEnabled: cfg.submissionEnabled,
|
||||||
MailLogPath: cfg.mailLog,
|
MailLogPath: cfg.mailLog,
|
||||||
DataDir: cfg.dataDir,
|
DataDir: cfg.dataDir,
|
||||||
DBPath: cfg.dbPath,
|
DBPath: cfg.dbPath,
|
||||||
Version: buildinfo.Version,
|
Version: buildinfo.Version,
|
||||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||||
TLSCertFile: cfg.tlsCertFile,
|
TLSCertFile: cfg.tlsCertFile,
|
||||||
OpenDKIMSocket: cfg.opendkimSocket,
|
OpenDKIMSocket: cfg.opendkimSocket,
|
||||||
JournalSocket: cfg.journalSocket,
|
JournalSocket: cfg.journalSocket,
|
||||||
SessionIdleDays: cfg.sessionIdleDays,
|
SessionIdleDays: cfg.sessionIdleDays,
|
||||||
DNSResolvers: cfg.dnsResolvers,
|
DNSResolvers: cfg.dnsResolvers,
|
||||||
|
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||||
|
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||||
}, cfg.setupTokenPath)
|
}, cfg.setupTokenPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ type config struct {
|
|||||||
trustedProxies []*net.IPNet
|
trustedProxies []*net.IPNet
|
||||||
sessionIdleDays int
|
sessionIdleDays int
|
||||||
dnsResolvers []string
|
dnsResolvers []string
|
||||||
|
rateLimitMessagesPerIP int
|
||||||
|
rateLimitWindowSeconds int
|
||||||
|
|
||||||
// Read-only inputs to the panel's status page: the certificate Postfix
|
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||||
// serves and the two milter sockets it connects to. The defaults mirror
|
// serves and the two milter sockets it connects to. The defaults mirror
|
||||||
@@ -117,6 +119,10 @@ func loadConfig() config {
|
|||||||
// means dnscheck's public defaults; a closed network names its own here.
|
// means dnscheck's public defaults; a closed network names its own here.
|
||||||
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
|
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
|
||||||
|
|
||||||
|
// Level-1 anvil defaults match build/postfix-config.sh / guide.md.
|
||||||
|
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
|
||||||
|
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
|
||||||
|
|
||||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||||
|
|
||||||
|
|||||||
@@ -81,11 +81,13 @@ One process, three roles:
|
|||||||
proxy only.
|
proxy only.
|
||||||
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||||
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
||||||
(`default_action=accept`) so milter failure does not stop mail. The level-2
|
(`default_action=accept`) so milter failure does not stop mail. Domain
|
||||||
count is the stored send-log rows plus the messages this process has admitted
|
ceilings apply to every client IP; an application ceiling with trusted IPs
|
||||||
but not yet written (`internal/milter/inflight.go`), so concurrent sessions
|
raises the limit for those IPs only and skips the domain check (guide § Rate
|
||||||
cannot each spend the same last slot; a reservation is released at
|
limiting). The level-2 count is the stored send-log rows plus the messages
|
||||||
end-of-message, on ABORT, or after a 10-minute TTL.
|
this process has admitted but not yet written (`internal/milter/inflight.go`),
|
||||||
|
so concurrent sessions cannot each spend the same last slot; a reservation
|
||||||
|
is released at end-of-message, on ABORT, or after a 10-minute TTL.
|
||||||
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||||
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
||||||
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
||||||
|
|||||||
+19
-10
@@ -202,7 +202,8 @@ service healthy and will mail be accepted?"
|
|||||||
shows after manual edits under `/data`.
|
shows after manual edits under `/data`.
|
||||||
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
|
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
|
||||||
TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits
|
TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits
|
||||||
(level 2) are configured here. *Export domain* writes a single-domain archive;
|
(level 2) and trusted-IP application overrides are configured here.
|
||||||
|
*Export domain* writes a single-domain archive;
|
||||||
*Import a domain* on the Backup page reads one back in.
|
*Import a domain* on the Backup page reads one back in.
|
||||||
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
|
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
|
||||||
by domain and application. A row identifies its message and nothing more —
|
by domain and application. A row identifies its message and nothing more —
|
||||||
@@ -269,8 +270,10 @@ docker compose exec selfpost cat /data/setup-token
|
|||||||
|
|
||||||
## Rate limiting
|
## Rate limiting
|
||||||
|
|
||||||
SelfPost applies two independent limits; both can refuse a submission, but only
|
SelfPost applies two independent layers; both can refuse a submission, but only
|
||||||
level 2 writes a `rejected` row in the send log.
|
level 2 writes a `rejected` row in the send log. Level-2 ceilings set in the
|
||||||
|
panel cannot exceed level 1 (the panel shows the level-1 values and rejects
|
||||||
|
higher numbers).
|
||||||
|
|
||||||
**Level 1 (IP backstop)** — always on, configured via `.env`:
|
**Level 1 (IP backstop)** — always on, configured via `.env`:
|
||||||
|
|
||||||
@@ -278,14 +281,20 @@ level 2 writes a `rejected` row in the send log.
|
|||||||
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
|
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
|
||||||
|
|
||||||
This is an anvil limit per connecting client IP. It keeps working even if the
|
This is an anvil limit per connecting client IP. It keeps working even if the
|
||||||
journal-milter (level 2) is down.
|
journal-milter (level 2) is down. There is no per-IP bypass.
|
||||||
|
|
||||||
**Level 2 (per domain / per application)** — optional, configured in the panel
|
**Level 2 — domain** — optional, on each domain's page. A message ceiling and
|
||||||
on each domain's page or on an individual application. You set a message
|
window for **every** client IP sending as that domain. When unset, only
|
||||||
ceiling, a time window, and optionally restrict the limit to specific client
|
level 1 applies for non-privileged senders.
|
||||||
IPs; an empty IP list means the differentiated limit does not apply. When
|
|
||||||
exceeded, Postfix returns a 4xx and the refusal is recorded in Deliveries as
|
**Level 2 — application (trusted IPs)** — optional override on an application:
|
||||||
`rejected`.
|
list one or more client IPs and a ceiling **strictly above** the domain limit
|
||||||
|
(still ≤ level 1). Connections from those IPs use the application ceiling and
|
||||||
|
skip the domain check. Other IPs stay under the domain limit (or level 1 alone).
|
||||||
|
An application override without trusted IPs is inactive.
|
||||||
|
|
||||||
|
When a level-2 ceiling is exceeded, Postfix returns a 4xx and the refusal is
|
||||||
|
recorded in Deliveries as `rejected`.
|
||||||
|
|
||||||
## Backup, restore, and moving a single domain
|
## Backup, restore, and moving a single domain
|
||||||
|
|
||||||
|
|||||||
@@ -189,9 +189,9 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
|||||||
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveRateLimit stores the application-level rate limit. The caller has validated
|
// SaveRateLimit stores the application-level trusted-IP override (guide § Rate
|
||||||
// the IPs and numbers (security.md); the milter reads the row live, so no reload
|
// limiting). The caller has validated the IPs and numbers (security.md); the
|
||||||
// is needed.
|
// milter reads the row live, so no reload is needed.
|
||||||
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||||
return s.store.SetRateLimit(store.RateLimit{
|
return s.store.SetRateLimit(store.RateLimit{
|
||||||
Scope: store.RateLimitScopeApp,
|
Scope: store.RateLimitScopeApp,
|
||||||
|
|||||||
@@ -145,13 +145,13 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
||||||
// IPs and numbers (security.md); the milter reads the row live, so no reload is
|
// numbers (security.md); the milter reads the row live, so no reload is needed.
|
||||||
// needed.
|
// Domain limits do not use an IP allowlist.
|
||||||
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
|
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||||
return s.store.SetRateLimit(store.RateLimit{
|
return s.store.SetRateLimit(store.RateLimit{
|
||||||
Scope: store.RateLimitScopeDomain,
|
Scope: store.RateLimitScopeDomain,
|
||||||
RefID: domainID,
|
RefID: domainID,
|
||||||
AllowedIPs: ips,
|
AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel
|
||||||
MaxMessages: maxMessages,
|
MaxMessages: maxMessages,
|
||||||
WindowSeconds: windowSeconds,
|
WindowSeconds: windowSeconds,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, erro
|
|||||||
return store.RateLimit{}, false, f.lookupErr
|
return store.RateLimit{}, false, f.lookupErr
|
||||||
}
|
}
|
||||||
rl, ok := f.limits[scope+"|"+ref]
|
rl, ok := f.limits[scope+"|"+ref]
|
||||||
|
if ok {
|
||||||
|
rl.Scope = scope
|
||||||
|
}
|
||||||
return rl, ok, nil
|
return rl, ok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,11 +189,14 @@ func TestBracedMacros(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitAt is the client IP the rate-limit tests connect from; the limits below
|
// limitIP is the client IP rate-limit tests connect from.
|
||||||
// register it so the differentiated check applies.
|
|
||||||
const limitIP = "203.0.113.7"
|
const limitIP = "203.0.113.7"
|
||||||
|
|
||||||
func activeLimit(ips ...string) store.RateLimit {
|
func domainLimit() store.RateLimit {
|
||||||
|
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appLimit(ips ...string) store.RateLimit {
|
||||||
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
|
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +218,7 @@ func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response {
|
|||||||
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
|
||||||
}
|
}
|
||||||
@@ -227,7 +233,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
|||||||
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
||||||
}
|
}
|
||||||
@@ -239,7 +245,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
|||||||
func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
|
||||||
}
|
}
|
||||||
@@ -251,25 +257,76 @@ func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitIgnoresUnregisteredIP(t *testing.T) {
|
func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit("198.51.100.1"), // not limitIP
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
// The sender's IP is not in the domain's registered set, so level-2 does not
|
// Domain ceilings apply to every client IP; leftover AllowedIPs on the row
|
||||||
// apply even though the count is huge (level-1 anvil would still cover it).
|
// are ignored.
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("domain over limit from any IP = %v, want TempFail", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitTrustedAppSkipsDomain(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||||
|
store.RateLimitScopeApp + "|app1": {
|
||||||
|
AllowedIPs: []string{limitIP}, MaxMessages: 10, WindowSeconds: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counts: map[string]int64{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": 5, // over domain
|
||||||
|
store.RateLimitScopeApp + "|app1": 2, // under app
|
||||||
|
},
|
||||||
|
}
|
||||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
t.Fatalf("unregistered IP = %v, want Continue (level-2 n/a)", resp)
|
t.Fatalf("trusted app under its ceiling = %v, want Continue (domain skipped)", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitUnlistedIPHitsDomain(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||||
|
store.RateLimitScopeApp + "|app1": {
|
||||||
|
AllowedIPs: []string{"198.51.100.1"}, MaxMessages: 100, WindowSeconds: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counts: map[string]int64{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": 1,
|
||||||
|
store.RateLimitScopeApp + "|app1": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("unlisted IP under domain = %v, want TempFail", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitAppWithoutIPsDoesNotPrivilege(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||||
|
store.RateLimitScopeApp + "|app1": {MaxMessages: 100, WindowSeconds: 3600}, // no IPs
|
||||||
|
},
|
||||||
|
counts: map[string]int64{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": 1,
|
||||||
|
store.RateLimitScopeApp + "|app1": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("app without IPs must not skip domain = %v, want TempFail", resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
// IP registered but no ceiling/window: an inert draft, must not enforce.
|
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}},
|
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
@@ -288,7 +345,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
|
|||||||
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
countErr: errors.New("db down"),
|
countErr: errors.New("db down"),
|
||||||
}
|
}
|
||||||
@@ -300,7 +357,7 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
|||||||
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
|
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
@@ -333,7 +390,7 @@ func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) (
|
|||||||
func limitedRecorder(count int64) *fakeRecorder {
|
func limitedRecorder(count int64) *fakeRecorder {
|
||||||
return &fakeRecorder{
|
return &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
|
||||||
}
|
}
|
||||||
@@ -395,17 +452,17 @@ func TestReservationReleasedOnAbort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A refused message must not leave the slots it claimed for the limits checked
|
// A trusted app at its ceiling refuses without touching the domain counter;
|
||||||
// before the one that tripped, or every refusal would tighten the ceiling.
|
// no domain reservation should linger after the refusal.
|
||||||
func TestRefusalReleasesEarlierReservation(t *testing.T) {
|
func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{
|
counts: map[string]int64{
|
||||||
store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room
|
store.RateLimitScopeDomain + "|example.com": 0,
|
||||||
store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling
|
store.RateLimitScopeApp + "|app1": 5, // app at ceiling
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
fl := &inflight{}
|
fl := &inflight{}
|
||||||
@@ -415,6 +472,9 @@ func TestRefusalReleasesEarlierReservation(t *testing.T) {
|
|||||||
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
|
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
|
||||||
t.Fatalf("domain reservation left behind after refusal: %d", n)
|
t.Fatalf("domain reservation left behind after refusal: %d", n)
|
||||||
}
|
}
|
||||||
|
if n := fl.count(store.RateLimitScopeApp+"|app1", time.Now().Add(-time.Hour)); n != 0 {
|
||||||
|
t.Fatalf("app reservation left behind after refusal: %d", n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The in-flight count only covers the limit's own window: a reservation older
|
// The in-flight count only covers the limit's own window: a reservation older
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// overLimit reports whether the message currently being received should be
|
// overLimit reports whether the message currently being received should be
|
||||||
// refused under a level-2 differentiated limit (guide § Rate limiting). It
|
// refused under a level-2 differentiated limit (guide § Rate limiting).
|
||||||
// checks the domain-level and application-level limits in turn; either being
|
//
|
||||||
// exceeded is enough to refuse.
|
// Trusted application IPs (app limit active and client IP listed) use only the
|
||||||
|
// app ceiling and skip the domain check. Everyone else is under the domain
|
||||||
|
// ceiling when one is configured; otherwise only level 1 applies.
|
||||||
//
|
//
|
||||||
// It is deliberately fail-open: any store error, or the absence of a usable
|
// It is deliberately fail-open: any store error, or the absence of a usable
|
||||||
// limit, is treated as "not over limit" so a malfunction of the level-2
|
// limit, is treated as "not over limit" so a malfunction of the level-2
|
||||||
@@ -26,46 +28,48 @@ func (s *session) overLimit() bool {
|
|||||||
if s.clientIP == "" {
|
if s.clientIP == "" {
|
||||||
return false // no client IP to key on; level-2 does not apply
|
return false // no client IP to key on; level-2 does not apply
|
||||||
}
|
}
|
||||||
checks := []struct{ scope, ref string }{
|
|
||||||
{store.RateLimitScopeDomain, domainOf(s.from)},
|
if s.login != "" {
|
||||||
{store.RateLimitScopeApp, s.login},
|
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
|
||||||
}
|
|
||||||
var taken []*reservation
|
|
||||||
for _, c := range checks {
|
|
||||||
if c.ref == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rl, ok, err := s.rec.RateLimit(c.scope, c.ref)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err)
|
log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
|
||||||
continue
|
} else if ok && rl.Active() && rl.AllowsIP(s.clientIP) {
|
||||||
|
return s.enforceLimit(store.RateLimitScopeApp, s.login, rl)
|
||||||
}
|
}
|
||||||
// No limit configured, an inert draft, or a client IP outside the
|
|
||||||
// registered set: the differentiated limit does not apply here.
|
|
||||||
if !ok || !rl.Active() || !rl.AllowsIP(s.clientIP) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
|
|
||||||
n, err := s.rec.CountMessages(c.scope, c.ref, since)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := c.scope + "|" + c.ref
|
|
||||||
n += s.flight.count(key, since)
|
|
||||||
if n >= int64(rl.MaxMessages) {
|
|
||||||
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
|
|
||||||
c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
|
|
||||||
// The message is refused, so the slots claimed for the limits
|
|
||||||
// checked before this one must not stay claimed.
|
|
||||||
for _, r := range taken {
|
|
||||||
s.flight.release(r)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
taken = append(taken, s.flight.reserve(key))
|
|
||||||
}
|
}
|
||||||
s.reserved = append(s.reserved, taken...)
|
|
||||||
|
domain := domainOf(s.from)
|
||||||
|
if domain == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeDomain, domain)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("journal-milter: rate-limit lookup domain %q: %v (fail-open)", domain, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !ok || !rl.Active() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.enforceLimit(store.RateLimitScopeDomain, domain, rl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceLimit counts recent messages for scope/ref and refuses when at or
|
||||||
|
// above the ceiling. On admit it reserves an in-flight slot on the session.
|
||||||
|
func (s *session) enforceLimit(scope, ref string, rl store.RateLimit) bool {
|
||||||
|
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
|
||||||
|
n, err := s.rec.CountMessages(scope, ref, since)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
key := scope + "|" + ref
|
||||||
|
n += s.flight.count(key, since)
|
||||||
|
if n >= int64(rl.MaxMessages) {
|
||||||
|
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
|
||||||
|
scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
s.reserved = append(s.reserved, s.flight.reserve(key))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,36 +18,40 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
|
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
|
||||||
// an optional set of expected client IPs plus a message ceiling over a sliding
|
// a message ceiling over a sliding window, attached to a domain or an
|
||||||
// window, attached to a domain or an application. It is enforced in the
|
// application. It is enforced in the journal-milter; level 1 (Postfix anvil,
|
||||||
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the
|
// architecture.md § Mail path) is the IP backstop that always applies even
|
||||||
// IP backstop that always applies even when this is absent or the milter is
|
// when this is absent or the milter is down.
|
||||||
// down.
|
|
||||||
//
|
//
|
||||||
// Both the IP binding and the ceiling are optional in the schema, but a limit
|
// Domain limits apply to every client IP once max and window are set. Application
|
||||||
// is only enforced when it is Active(): the design deliberately allows an
|
// limits additionally require AllowedIPs: those trusted addresses get the app
|
||||||
// admin to leave the IP binding empty for apps that send from changing IPs, in
|
// ceiling (above the domain) and skip the domain check; other IPs stay under
|
||||||
// which case only level 1 protects them (guide § Rate limiting).
|
// the domain limit or level 1 alone (guide § Rate limiting).
|
||||||
type RateLimit struct {
|
type RateLimit struct {
|
||||||
Scope string
|
Scope string
|
||||||
RefID int64
|
RefID int64
|
||||||
AllowedIPs []string // canonical client IPs this limit applies to
|
AllowedIPs []string // trusted client IPs for an application override
|
||||||
MaxMessages int
|
MaxMessages int
|
||||||
WindowSeconds int
|
WindowSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active reports whether the limit is fully configured and should be enforced.
|
// Active reports whether the limit is fully configured and should be enforced.
|
||||||
// A missing IP binding, ceiling or window leaves the differentiated limit
|
// Domain: max and window only. Application: also needs at least one trusted IP
|
||||||
// inert (guide § Rate limiting): the IP binding is what scopes the limit to a
|
// (the privilege that raises the ceiling above the domain).
|
||||||
// known sender.
|
|
||||||
func (r RateLimit) Active() bool {
|
func (r RateLimit) Active() bool {
|
||||||
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
|
if r.MaxMessages <= 0 || r.WindowSeconds <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.Scope == RateLimitScopeApp {
|
||||||
|
return len(r.AllowedIPs) > 0
|
||||||
|
}
|
||||||
|
// Domain (and any unset/legacy scope treated as domain-style): no IP list.
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowsIP reports whether ip is one of the limit's registered client IPs. The
|
// AllowsIP reports whether ip is one of the application's trusted client IPs.
|
||||||
// comparison parses both sides so equivalent textual forms of the same address
|
// Used only for application overrides; domain limits do not consult this list.
|
||||||
// match; a client IP outside the list means the differentiated limit does not
|
// Equivalent textual forms of the same address match.
|
||||||
// apply to it (level 1 still does).
|
|
||||||
func (r RateLimit) AllowsIP(ip string) bool {
|
func (r RateLimit) AllowsIP(ip string) bool {
|
||||||
c := net.ParseIP(ip)
|
c := net.ParseIP(ip)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
@@ -183,8 +187,8 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
||||||
// (an IP-only draft) by leaving the corresponding field zero, which makes the
|
// by leaving the corresponding field zero, which makes the limit inert via
|
||||||
// limit inert via Active().
|
// Active() until max and window are both set.
|
||||||
func scanRateLimit(r scanRow) (RateLimit, error) {
|
func scanRateLimit(r scanRow) (RateLimit, error) {
|
||||||
var (
|
var (
|
||||||
ips sql.NullString
|
ips sql.NullString
|
||||||
|
|||||||
@@ -152,27 +152,35 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
|
|||||||
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
|
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
|
||||||
inactive := []RateLimit{
|
inactive := []RateLimit{
|
||||||
{},
|
{},
|
||||||
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
||||||
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
|
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||||
{MaxMessages: 5, WindowSeconds: 60}, // no IPs
|
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs
|
||||||
|
{Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
|
||||||
}
|
}
|
||||||
for i, rl := range inactive {
|
for i, rl := range inactive {
|
||||||
if rl.Active() {
|
if rl.Active() {
|
||||||
t.Fatalf("case %d: %+v should be inactive", i, rl)
|
t.Fatalf("case %d: %+v should be inactive", i, rl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
active := RateLimit{AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, MaxMessages: 5, WindowSeconds: 60}
|
domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60}
|
||||||
if !active.Active() {
|
if !domainActive.Active() {
|
||||||
t.Fatalf("should be active: %+v", active)
|
t.Fatalf("domain without IPs should be active: %+v", domainActive)
|
||||||
}
|
}
|
||||||
if !active.AllowsIP("203.0.113.1") || !active.AllowsIP("2001:db8::1") {
|
appActive := RateLimit{
|
||||||
|
Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1", "2001:db8::1"},
|
||||||
|
MaxMessages: 5, WindowSeconds: 60,
|
||||||
|
}
|
||||||
|
if !appActive.Active() {
|
||||||
|
t.Fatalf("should be active: %+v", appActive)
|
||||||
|
}
|
||||||
|
if !appActive.AllowsIP("203.0.113.1") || !appActive.AllowsIP("2001:db8::1") {
|
||||||
t.Fatalf("registered IPs should match")
|
t.Fatalf("registered IPs should match")
|
||||||
}
|
}
|
||||||
// Equivalent textual form of the IPv6 address must still match.
|
// Equivalent textual form of the IPv6 address must still match.
|
||||||
if !active.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
if !appActive.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
||||||
t.Fatalf("expanded IPv6 form should match")
|
t.Fatalf("expanded IPv6 form should match")
|
||||||
}
|
}
|
||||||
if active.AllowsIP("198.51.100.7") || active.AllowsIP("not-an-ip") || active.AllowsIP("") {
|
if appActive.AllowsIP("198.51.100.7") || appActive.AllowsIP("not-an-ip") || appActive.AllowsIP("") {
|
||||||
t.Fatalf("unregistered/invalid IPs must not match")
|
t.Fatalf("unregistered/invalid IPs must not match")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ type Config struct {
|
|||||||
TLSCertFile string
|
TLSCertFile string
|
||||||
OpenDKIMSocket string
|
OpenDKIMSocket string
|
||||||
JournalSocket 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.
|
// 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["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
|
||||||
data["Error"] = formErr
|
data["Error"] = formErr
|
||||||
data["Flash"] = accountFlash(r)
|
data["Flash"] = accountFlash(r)
|
||||||
|
data["L1Messages"] = h.l1Messages()
|
||||||
|
data["L1Window"] = h.l1Window()
|
||||||
h.view.Render(w, status, "settings", data)
|
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["ExportErr"] = view.ExportErr
|
||||||
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
||||||
data["DomainHasRL"] = domainRLok && domainRL.Active()
|
data["DomainHasRL"] = domainRLok && domainRL.Active()
|
||||||
data["DomainRLIPs"] = strings.Join(domainRL.AllowedIPs, "\n")
|
|
||||||
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
|
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
|
||||||
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
|
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)
|
h.view.Render(w, status, "domain_detail", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,24 +19,73 @@ type rateLimitInput struct {
|
|||||||
windowSeconds int
|
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 {
|
if err := r.ParseForm(); err != nil {
|
||||||
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||||
}
|
}
|
||||||
if r.PostFormValue("clear") != "" {
|
if r.PostFormValue("clear") != "" {
|
||||||
return rateLimitInput{clear: true}, nil
|
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"))
|
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rateLimitInput{}, err
|
return rateLimitInput{}, err
|
||||||
}
|
}
|
||||||
if len(ips) == 0 {
|
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 {
|
if err != nil || maxMessages <= 0 {
|
||||||
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
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)
|
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
||||||
if err != nil || windowSeconds <= 0 {
|
if err != nil || windowSeconds <= 0 {
|
||||||
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
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 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := parseRateLimitForm(r)
|
in, err := parseDomainRateLimitForm(r, h.l1Messages())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
@@ -103,7 +152,14 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="check-col">
|
<div class="check-col">
|
||||||
<p class="check-col-title">Optional level-2 rate limit</p>
|
<p class="check-col-title">Optional trusted-IP override</p>
|
||||||
<p class="muted">Caps this application only. The domain level-2
|
<p class="muted">Listed client IPs get a higher ceiling than the
|
||||||
limit (<a href="#domain-settings">Domain settings</a>) and the
|
domain limit (still capped by level 1:
|
||||||
global level-1 limit still apply.</p>
|
{{$.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">
|
<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>
|
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
|
||||||
<p class="muted">The limit counts only connections from these
|
<p class="muted">Only these IPs use the application ceiling and
|
||||||
IPs. Leave empty to leave the limit inactive.</p>
|
skip the domain limit. Other IPs stay under the domain (or
|
||||||
|
level 1).</p>
|
||||||
<div class="field-pair">
|
<div class="field-pair">
|
||||||
<div>
|
<div>
|
||||||
<label>Message limit</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label>Window (seconds)</label>
|
<label>Window (seconds)</label>
|
||||||
@@ -370,7 +374,7 @@
|
|||||||
<button type="submit" form="rl-{{.ID}}">Save limit</button>
|
<button type="submit" form="rl-{{.ID}}">Save limit</button>
|
||||||
{{if .HasLimit}}
|
{{if .HasLimit}}
|
||||||
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
|
<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">
|
<input type="hidden" name="clear" value="1">
|
||||||
<button type="submit" class="danger">Remove limit</button>
|
<button type="submit" class="danger">Remove limit</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -417,21 +421,18 @@
|
|||||||
|
|
||||||
<div class="check-col">
|
<div class="check-col">
|
||||||
<p class="check-col-title">Optional level-2 sending rate limit</p>
|
<p class="check-col-title">Optional level-2 sending rate limit</p>
|
||||||
<p class="muted">Empty IP list = inactive (level-1 only). Status:
|
<p class="muted">Applies to every client IP on this domain. Status:
|
||||||
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive{{end}}.</p>
|
{{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">
|
<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 class="field-pair">
|
||||||
<div>
|
<div>
|
||||||
<label for="d_max">Message limit</label>
|
<label for="d_max">Message limit</label>
|
||||||
<input id="d_max" name="max_messages" type="number" min="1"
|
<input id="d_max" name="max_messages" type="number" min="1" max="{{.L1Messages}}"
|
||||||
value="{{.DomainRLMax}}" placeholder="1000">
|
value="{{.DomainRLMax}}" placeholder="{{.L1Messages}}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="d_win">Window (seconds)</label>
|
<label for="d_win">Window (seconds)</label>
|
||||||
|
|||||||
@@ -102,4 +102,26 @@ this one stays signed in.</p>
|
|||||||
this one stays signed in.</p>
|
this one stays signed in.</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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}}
|
{{end}}
|
||||||
|
|||||||
@@ -241,15 +241,40 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
|
|||||||
if strings.Contains(src, `id="rate-limit"`) {
|
if strings.Contains(src, `id="rate-limit"`) {
|
||||||
t.Error("domain rate limit should live inside domain-settings, not its own card")
|
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"`) {
|
if strings.Contains(src, `id="spf-dmarc"`) {
|
||||||
t.Error("SPF should sit with DKIM, not with 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
|
func TestSettingsPageDocumentsRateLimits(t *testing.T) {
|
||||||
// cards. A link at the bottom of a form is easy to miss and drifts from the
|
body, err := fs.ReadFile(assetsFS, "templates/settings.html")
|
||||||
// rest of the panel, so the shared back_link template is mandatory on those
|
if err != nil {
|
||||||
// pages and TestDrillDownPagesPlaceBackLinkAboveContent guards its position.
|
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) {
|
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||||
drillDown := map[string]bool{
|
drillDown := map[string]bool{
|
||||||
"user_form.html": true,
|
"user_form.html": true,
|
||||||
|
|||||||
+16
-9
@@ -70,6 +70,11 @@ type Config struct {
|
|||||||
// checks must not go through the system resolver — see dnscheck's
|
// checks must not go through the system resolver — see dnscheck's
|
||||||
// externalResolver — so this is how a closed network points them at its own.
|
// externalResolver — so this is how a closed network points them at its own.
|
||||||
DNSResolvers []string
|
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.
|
// 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,
|
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
||||||
}, v, setupTokenPath)
|
}, v, setupTokenPath)
|
||||||
h := handlers.New(st, domains, apps, handlers.Config{
|
h := handlers.New(st, domains, apps, handlers.Config{
|
||||||
Hostname: cfg.Hostname,
|
Hostname: cfg.Hostname,
|
||||||
SubmissionEnabled: cfg.SubmissionEnabled,
|
SubmissionEnabled: cfg.SubmissionEnabled,
|
||||||
MailLogPath: cfg.MailLogPath,
|
MailLogPath: cfg.MailLogPath,
|
||||||
DataDir: cfg.DataDir,
|
DataDir: cfg.DataDir,
|
||||||
DBPath: cfg.DBPath,
|
DBPath: cfg.DBPath,
|
||||||
Version: cfg.Version,
|
Version: cfg.Version,
|
||||||
TLSCertFile: cfg.TLSCertFile,
|
TLSCertFile: cfg.TLSCertFile,
|
||||||
OpenDKIMSocket: cfg.OpenDKIMSocket,
|
OpenDKIMSocket: cfg.OpenDKIMSocket,
|
||||||
JournalSocket: cfg.JournalSocket,
|
JournalSocket: cfg.JournalSocket,
|
||||||
|
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
|
||||||
|
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||||
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
||||||
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,15 +194,18 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a
|
|||||||
return appLogin, password, nil
|
return appLogin, password, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setRateLimit saves a level-2 differentiated limit (guide § Rate limiting)
|
// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
|
||||||
// on either a domain (/domains/{id}/ratelimit) or an application
|
// (/applications/{id}/ratelimit). allowedIP is required for the trusted-IP
|
||||||
// (/applications/{id}/ratelimit).
|
// override; domain ceilings are posted without IPs.
|
||||||
func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error {
|
func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error {
|
||||||
resp, body, err := c.postForm(path, url.Values{
|
vals := url.Values{
|
||||||
"allowed_ips": {allowedIP},
|
|
||||||
"max_messages": {fmt.Sprintf("%d", maxMessages)},
|
"max_messages": {fmt.Sprintf("%d", maxMessages)},
|
||||||
"window_seconds": {fmt.Sprintf("%d", windowSeconds)},
|
"window_seconds": {fmt.Sprintf("%d", windowSeconds)},
|
||||||
})
|
}
|
||||||
|
if allowedIP != "" {
|
||||||
|
vals.Set("allowed_ips", allowedIP)
|
||||||
|
}
|
||||||
|
resp, body, err := c.postForm(path, vals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user