Compare commits
4 Commits
f9e259a66d
...
6671bd3393
| Author | SHA1 | Date | |
|---|---|---|---|
| 6671bd3393 | |||
| 00e36df553 | |||
| b0ebe061b5 | |||
| 601e183e0c |
+38
-8
@@ -5,17 +5,47 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.4] - 2026-08-12
|
||||||
|
|
||||||
|
Level-2 rate-limit semantics inverted after 1.2.3, plus a small DNS field
|
||||||
|
height fix. Upgrading is a tag bump; no migration.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- panel: the domain detail page is wide with paired cards (DKIM ‖ SPF+DMARC;
|
- rate limiting (level 2): domain ceilings apply to every client IP (no IP
|
||||||
sending settings ‖ add application; export ‖ danger). DNS status,
|
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
|
||||||
|
|
||||||
|
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no
|
||||||
|
migration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC;
|
||||||
|
connection settings ‖ add application; export ‖ danger). DNS status,
|
||||||
Applications and Domain settings are full-width. DNS status is two rows
|
Applications and Domain settings are full-width. DNS status is two rows
|
||||||
(DKIM ‖ SPF, DMARC ‖ report authorization); Domain settings pairs DMARC
|
(DKIM ‖ SPF, DMARC ‖ report authorization) with Host ‖ Type (narrow TXT)
|
||||||
report mode with the domain rate limit; application Edit opens mode and
|
and a Value label when records are present. Domain settings pairs DMARC
|
||||||
rate limit side by side; the custom rua address field is shown only for
|
report mode with the optional level-2 domain rate limit; application Edit
|
||||||
Custom address. The in-nav “On this page” section index is removed (Status
|
opens address mode and an optional level-2 application rate limit side by
|
||||||
already dropped it; the paired domain layout no longer needs it). Section
|
side (with a note that domain level-2 and global level-1 still apply); the
|
||||||
blurbs are shorter.
|
custom `rua=` address field is shown only for Custom address. The in-nav
|
||||||
|
“On this page” section index is removed. Shorter blurbs; *Sending server
|
||||||
|
settings* renamed **Connection settings**.
|
||||||
|
- panel: Domains list — **Add domain** sits beside the domain field; the
|
||||||
|
lead blurb under the form is dropped.
|
||||||
- panel: page URLs, browser titles, and headings are aligned — **Settings** is
|
- panel: page URLs, browser titles, and headings are aligned — **Settings** is
|
||||||
now `/settings` (legacy `/account` redirects with 308); the domains list title
|
now `/settings` (legacy `/account` redirects with 308); the domains list title
|
||||||
is `SelfPost — domains`; Status, Users, and user create/edit titles match their
|
is `SelfPost — domains`; Status, Users, and user create/edit titles match their
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ docker run --rm -d --name selfpost-try \
|
|||||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||||
-e PANEL_COOKIE_SECURE=false \
|
-e PANEL_COOKIE_SECURE=false \
|
||||||
-v selfpost-try-data:/data \
|
-v selfpost-try-data:/data \
|
||||||
ghcr.io/mixeme/selfpost:1.2.2
|
ghcr.io/mixeme/selfpost:1.2.4
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
|
|||||||
+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"),
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:1.2.2
|
image: ghcr.io/mixeme/selfpost:1.2.4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
|
|||||||
@@ -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)).
|
||||||
|
|||||||
+1
-1
@@ -141,7 +141,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
||||||
make build VERSION=1.2.2
|
make build VERSION=1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
Or directly:
|
Or directly:
|
||||||
|
|||||||
+20
-11
@@ -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
|
||||||
|
|
||||||
@@ -381,7 +390,7 @@ but it can look like an open port in external scans.
|
|||||||
## Fixed image tag
|
## Fixed image tag
|
||||||
|
|
||||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||||
deliberately never `:latest`. The current pin is `1.2.2`. Intermediate
|
deliberately never `:latest`. The current pin is `1.2.4`. Intermediate
|
||||||
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
||||||
image was published. Pinning matters because of the backup version check above:
|
image was published. Pinning matters because of the backup version check above:
|
||||||
the panel binary's embedded version and the image tag that produced it are the
|
the panel binary's embedded version and the image tag that produced it are the
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -508,8 +508,20 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
|
|||||||
beside them would otherwise win the width and leave "Memory" broken across
|
beside them would otherwise win the width and leave "Memory" broken across
|
||||||
two lines. */
|
two lines. */
|
||||||
.metric { white-space: nowrap; }
|
.metric { white-space: nowrap; }
|
||||||
|
/* Copy sits at the top of the value — right for a long DKIM key. The row's
|
||||||
|
.code uses the compact button's vertical padding so a one-line value is the
|
||||||
|
same height as Copy and does not leave the button hanging under an empty
|
||||||
|
band of padding. */
|
||||||
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||||
.code-row .code { flex: 1; min-width: 0; }
|
.code-row .code {
|
||||||
|
flex: 1; min-width: 0; margin-top: 0;
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
/* A short field with its submit on the same row (Add domain). The button
|
||||||
|
keeps the card's usual filled look; its top margin is for stacked forms. */
|
||||||
|
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
|
||||||
|
.input-row input { flex: 1; min-width: 0; }
|
||||||
|
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
|
||||||
|
|
||||||
/* Two cards abreast, on the one page that has a pair of them worth reading
|
/* Two cards abreast, on the one page that has a pair of them worth reading
|
||||||
together (a delivery's facts beside its history). auto-fit with a minimum
|
together (a delivery's facts beside its history). auto-fit with a minimum
|
||||||
@@ -557,6 +569,19 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
|
|||||||
}
|
}
|
||||||
.field-pair > div { min-width: 0; }
|
.field-pair > div { min-width: 0; }
|
||||||
.field-pair label { margin-top: 0.45rem; }
|
.field-pair label { margin-top: 0.45rem; }
|
||||||
|
/* Host / name beside a DNS Type that is always TXT — size the type column to
|
||||||
|
that token rather than giving it half the row. Vertical padding matches
|
||||||
|
.code-row .code so TXT is the same height as Host when Copy sits beside it. */
|
||||||
|
.field-pair.host-type {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.field-pair.host-type .code {
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-type { width: max-content; }
|
||||||
|
.field-type .code {
|
||||||
|
width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box;
|
||||||
|
}
|
||||||
/* The subject heads a delivery's page, and it is the one heading in the panel
|
/* The subject heads a delivery's page, and it is the one heading in the panel
|
||||||
whose text we do not control: it may be a hundred characters with nothing to
|
whose text we do not control: it may be a hundred characters with nothing to
|
||||||
break on. It wraps to as many lines as it needs (this is the page's name, not
|
break on. It wraps to as many lines as it needs (this is the page's name, not
|
||||||
@@ -646,7 +671,7 @@ button.copy, .actions button, .actions > label.toggle, .actions a.danger, .nav b
|
|||||||
}
|
}
|
||||||
button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
|
button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
|
||||||
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
|
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
|
||||||
button.copy { flex: none; margin-top: 0.3rem; }
|
button.copy { flex: none; }
|
||||||
.actions button.danger, .actions a.danger, .nav button.danger {
|
.actions button.danger, .actions a.danger, .nav button.danger {
|
||||||
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
|
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
<h2>Add a sending domain</h2>
|
<h2>Add a sending domain</h2>
|
||||||
<form method="post" action="/domains">
|
<form method="post" action="/domains">
|
||||||
<label for="name">Domain</label>
|
<label for="name">Domain</label>
|
||||||
<input id="name" name="name" type="text" placeholder="example.com"
|
<div class="input-row">
|
||||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
<input id="name" name="name" type="text" placeholder="example.com"
|
||||||
value="{{.FormName}}" autofocus required>
|
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||||
|
value="{{.FormName}}" autofocus required>
|
||||||
|
<button type="submit">Add domain</button>
|
||||||
|
</div>
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
<button type="submit">Add domain</button>
|
|
||||||
</form>
|
</form>
|
||||||
<p class="muted">A DKIM key is generated for the domain; you then publish the
|
|
||||||
shown DNS record. Adding a domain does not create an application.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -37,39 +37,93 @@
|
|||||||
<div class="check-cols">
|
<div class="check-cols">
|
||||||
<div class="check-col">
|
<div class="check-col">
|
||||||
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
||||||
<p class="muted">{{.Record.Name}}</p>
|
<div class="field-pair host-type">
|
||||||
|
<div>
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">{{.Record.Name}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .DNS.DKIM.Records}}
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">{{range .DNS.DKIM.Records}}{{.}}
|
||||||
|
{{end}}</span>
|
||||||
|
{{end}}
|
||||||
{{if ne .DNS.DKIM.Status "ok"}}
|
{{if ne .DNS.DKIM.Status "ok"}}
|
||||||
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
|
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
|
|
||||||
{{end}}</span>{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="check-col">
|
<div class="check-col">
|
||||||
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
|
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
|
||||||
<p class="muted">{{.Domain.Name}}</p>
|
<div class="field-pair host-type">
|
||||||
|
<div>
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">{{.Domain.Name}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .DNS.SPF.Records}}
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">{{range .DNS.SPF.Records}}{{.}}
|
||||||
|
{{end}}</span>
|
||||||
|
{{end}}
|
||||||
{{if ne .DNS.SPF.Status "ok"}}
|
{{if ne .DNS.SPF.Status "ok"}}
|
||||||
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
|
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
<p class="muted">Shallow check: literal address only, no <code>include:</code> /
|
<p class="muted">Shallow check: literal address only, no <code>include:</code> /
|
||||||
<code>redirect=</code>.</p>
|
<code>redirect=</code>.</p>
|
||||||
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
|
|
||||||
{{end}}</span>{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="check-col">
|
<div class="check-col">
|
||||||
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
|
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
|
||||||
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
|
<div class="field-pair host-type">
|
||||||
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
<div>
|
||||||
{{end}}</span>{{end}}
|
<label>Host / name</label>
|
||||||
|
<span class="code">{{.DMARCName}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .DNS.DMARC.Records}}
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
||||||
|
{{end}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{if ne .DNS.DMARC.Status "ok"}}
|
||||||
|
<p class="{{if eq .DNS.DMARC.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">{{.DNS.DMARC.Detail}}</p>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="check-col">
|
<div class="check-col">
|
||||||
{{if .DNS.DMARCReportAuth.Status}}
|
{{if .DNS.DMARCReportAuth.Status}}
|
||||||
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
||||||
|
<div class="field-pair host-type">
|
||||||
|
<div>
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">{{.ReportAuthName}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .DNS.DMARCReportAuth.Records}}
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
||||||
|
{{end}}</span>
|
||||||
|
{{end}}
|
||||||
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
||||||
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
|
||||||
{{end}}</span>{{end}}
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<label>Report authorization</label>
|
<label>Report authorization</label>
|
||||||
<p class="muted">Not required (no external <code>rua=</code>).</p>
|
<p class="muted">Not required (no external <code>rua=</code>).</p>
|
||||||
@@ -83,18 +137,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="card" id="dkim">
|
<div class="card" id="dkim-spf">
|
||||||
<h2>DKIM record</h2>
|
<h2>DKIM and SPF records</h2>
|
||||||
|
|
||||||
<label>Host / name</label>
|
<p class="check-col-title">DKIM</p>
|
||||||
<div class="code-row">
|
<div class="field-pair host-type">
|
||||||
<span class="code">{{.Record.Name}}</span>
|
<div>
|
||||||
<button type="button" class="copy">Copy</button>
|
<label>Host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.Record.Name}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>Type</label>
|
|
||||||
<span class="code">TXT</span>
|
|
||||||
|
|
||||||
<label>Value</label>
|
<label>Value</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.Record.Value}}</span>
|
<span class="code">{{.Record.Value}}</span>
|
||||||
@@ -103,21 +163,22 @@
|
|||||||
|
|
||||||
<p class="muted">Not a secret. Signed with selector
|
<p class="muted">Not a secret. Signed with selector
|
||||||
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="spf-dmarc">
|
|
||||||
<h2>SPF and DMARC records</h2>
|
|
||||||
|
|
||||||
<p class="check-col-title">SPF</p>
|
<p class="check-col-title">SPF</p>
|
||||||
<label>Host / name</label>
|
<div class="field-pair host-type">
|
||||||
<div class="code-row">
|
<div>
|
||||||
<span class="code">{{.Domain.Name}}</span>
|
<label>Host / name</label>
|
||||||
<button type="button" class="copy">Copy</button>
|
<div class="code-row">
|
||||||
|
<span class="code">{{.Domain.Name}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>Type</label>
|
|
||||||
<span class="code">TXT</span>
|
|
||||||
|
|
||||||
<label>Value</label>
|
<label>Value</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.SPFExample}}</span>
|
<span class="code">{{.SPFExample}}</span>
|
||||||
@@ -126,17 +187,25 @@
|
|||||||
|
|
||||||
<p class="muted">Merge into an existing SPF if the domain already has one —
|
<p class="muted">Merge into an existing SPF if the domain already has one —
|
||||||
do not publish a second record.</p>
|
do not publish a second record.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="check-col-title">DMARC</p>
|
<div class="card" id="dmarc">
|
||||||
<label>Host / name</label>
|
<h2>DMARC record</h2>
|
||||||
<div class="code-row">
|
|
||||||
<span class="code">{{.DMARCName}}</span>
|
<div class="field-pair host-type">
|
||||||
<button type="button" class="copy">Copy</button>
|
<div>
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.DMARCName}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>Type</label>
|
|
||||||
<span class="code">TXT</span>
|
|
||||||
|
|
||||||
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.DMARCExample}}</span>
|
<span class="code">{{.DMARCExample}}</span>
|
||||||
@@ -151,15 +220,20 @@
|
|||||||
|
|
||||||
{{if .NeedsReportAuth}}
|
{{if .NeedsReportAuth}}
|
||||||
<p class="check-col-title">Report authorization</p>
|
<p class="check-col-title">Report authorization</p>
|
||||||
<label>Host / name</label>
|
<div class="field-pair host-type">
|
||||||
<div class="code-row">
|
<div>
|
||||||
<span class="code">{{.ReportAuthName}}</span>
|
<label>Host / name</label>
|
||||||
<button type="button" class="copy">Copy</button>
|
<div class="code-row">
|
||||||
|
<span class="code">{{.ReportAuthName}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>Type</label>
|
|
||||||
<span class="code">TXT</span>
|
|
||||||
|
|
||||||
<label>Value</label>
|
<label>Value</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.ReportAuthValue}}</span>
|
<span class="code">{{.ReportAuthValue}}</span>
|
||||||
@@ -174,8 +248,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="card" id="settings">
|
<div class="card" id="connection">
|
||||||
<h2>Sending server settings</h2>
|
<h2>Connection settings</h2>
|
||||||
<p class="muted">Same for every domain. Authenticate with an application
|
<p class="muted">Same for every domain. Authenticate with an application
|
||||||
login from below.</p>
|
login from below.</p>
|
||||||
|
|
||||||
@@ -193,8 +267,8 @@
|
|||||||
create or regenerate.</p>
|
create or regenerate.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* Create form beside settings, mirroring "Add a sending domain" above the
|
{{/* Create form beside connection settings, mirroring "Add a sending domain"
|
||||||
domains list. */}}
|
above the domains list. */}}
|
||||||
<div class="card" id="add-application">
|
<div class="card" id="add-application">
|
||||||
<h2>Add an application</h2>
|
<h2>Add an application</h2>
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
||||||
@@ -270,16 +344,23 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="check-col">
|
<div class="check-col">
|
||||||
<p class="check-col-title">Rate limit</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">
|
<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>
|
||||||
@@ -293,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>
|
||||||
@@ -340,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}}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The domain page pairs cards the same way Status does: three .split rows
|
// The domain page pairs cards the same way Status does: three .split rows
|
||||||
// (DKIM|SPF+DMARC, settings|add-app, export|danger). DNS status, Applications
|
// (DKIM+SPF|DMARC, connection|add-app, export|danger). DNS status, Applications
|
||||||
// and Domain settings are full-width; DNS status and Domain settings (and the
|
// and Domain settings are full-width; DNS status and Domain settings (and the
|
||||||
// application Edit panel) use .check-cols. Losing a row silently stacks again.
|
// application Edit panel) use .check-cols. Losing a row silently stacks again.
|
||||||
func TestDomainDetailPageHasPairedCards(t *testing.T) {
|
func TestDomainDetailPageHasPairedCards(t *testing.T) {
|
||||||
@@ -230,8 +230,8 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
|
|||||||
t.Error("application Edit mode and Rate limit should be one Edit button")
|
t.Error("application Edit mode and Rate limit should be one Edit button")
|
||||||
}
|
}
|
||||||
for _, id := range []string{
|
for _, id := range []string{
|
||||||
`id="dkim"`, `id="dns-status"`, `id="spf-dmarc"`,
|
`id="dkim-spf"`, `id="dns-status"`, `id="dmarc"`,
|
||||||
`id="settings"`, `id="add-application"`, `id="applications"`,
|
`id="connection"`, `id="add-application"`, `id="applications"`,
|
||||||
`id="domain-settings"`, `id="export"`, `id="danger"`,
|
`id="domain-settings"`, `id="export"`, `id="danger"`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(src, id) {
|
if !strings.Contains(src, id) {
|
||||||
@@ -241,18 +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="spf"`) && !strings.Contains(src, `id="spf-dmarc"`) {
|
if strings.Contains(src, `id="d_ips"`) {
|
||||||
t.Error("standalone SPF card should be merged into spf-dmarc")
|
t.Error("domain rate limit must not ask for client IPs")
|
||||||
}
|
}
|
||||||
if regexp.MustCompile(`id="dmarc"`).MatchString(src) {
|
if !strings.Contains(src, "level 1") {
|
||||||
t.Error("standalone DMARC card should be merged into spf-dmarc")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 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