Compare commits

...

4 Commits

Author SHA1 Message Date
mix 6671bd3393 release: 1.2.4
test / test (push) Has been cancelled
release / prepare (push) Has been cancelled
release / build (amd64, ubuntu-latest) (push) Has been cancelled
release / build (arm64, ubuntu-24.04-arm) (push) Has been cancelled
release / merge (push) Has been cancelled
Close Unreleased for the level-2 rate-limit semantics change and DNS Type
field height fix; pin compose and docs to 1.2.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:21:28 +03:00
mix 00e36df553 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>
2026-08-12 23:19:51 +03:00
mix b0ebe061b5 panel: match DNS Type TXT height to Host fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:06:09 +03:00
mix 601e183e0c release: 1.2.3
Close CHANGELOG [Unreleased] as 1.2.3; bump the compose pin and image references. Includes domain detail layout polish and Domains add-row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 22:52:19 +03:00
26 changed files with 684 additions and 245 deletions
+38 -8
View File
@@ -5,17 +5,47 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [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
- panel: the domain detail page is wide with paired cards (DKIM ‖ SPF+DMARC;
sending settings ‖ add application; export ‖ danger). DNS status,
- 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
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
(DKIM ‖ SPF, DMARC ‖ report authorization); Domain settings pairs DMARC
report mode with the domain rate limit; application Edit opens mode and
rate limit side by side; the custom rua address field is shown only for
Custom address. The in-nav “On this page” section index is removed (Status
already dropped it; the paired domain layout no longer needs it). Section
blurbs are shorter.
(DKIM ‖ SPF, DMARC ‖ report authorization) with Host ‖ Type (narrow TXT)
and a Value label when records are present. Domain settings pairs DMARC
report mode with the optional level-2 domain rate limit; application Edit
opens address mode and an optional level-2 application rate limit side by
side (with a note that domain level-2 and global level-1 still apply); the
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
now `/settings` (legacy `/account` redirects with 308); the domains list title
is `SelfPost — domains`; Status, Users, and user create/edit titles match their
+2 -2
View File
@@ -24,7 +24,7 @@ send log and DNS checks in the panel, encrypted backups.
- Web panel — domains, applications, deliveries, mail queue, system log, backup
- Multi-domain relay — each SASL application is bound to one sending domain
- 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)
- 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 PANEL_COOKIE_SECURE=false \
-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):
+2
View File
@@ -39,6 +39,8 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
JournalSocket: cfg.journalSocket,
SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
}, cfg.setupTokenPath)
if err != nil {
return err
+6
View File
@@ -63,6 +63,8 @@ type config struct {
trustedProxies []*net.IPNet
sessionIdleDays int
dnsResolvers []string
rateLimitMessagesPerIP int
rateLimitWindowSeconds int
// Read-only inputs to the panel's status page: the certificate Postfix
// 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.
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"),
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
+1 -1
View File
@@ -22,7 +22,7 @@
services:
selfpost:
image: ghcr.io/mixeme/selfpost:1.2.2
image: ghcr.io/mixeme/selfpost:1.2.4
restart: unless-stopped
environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+7 -5
View File
@@ -81,11 +81,13 @@ One process, three roles:
proxy only.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
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
count is the stored send-log rows plus the messages 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.
(`default_action=accept`) so milter failure does not stop mail. Domain
ceilings apply to every client IP; an application ceiling with trusted IPs
raises the limit for those IPs only and skips the domain check (guide § Rate
limiting). The level-2 count is the stored send-log rows plus the messages
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
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
+1 -1
View File
@@ -141,7 +141,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`.
```sh
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:
+20 -11
View File
@@ -202,7 +202,8 @@ service healthy and will mail be accepted?"
shows after manual edits under `/data`.
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
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.
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
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
SelfPost applies two independent limits; both can refuse a submission, but only
level 2 writes a `rejected` row in the send log.
SelfPost applies two independent layers; both can refuse a submission, but only
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`:
@@ -278,14 +281,20 @@ level 2 writes a `rejected` row in the send log.
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
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
on each domain's page or on an individual application. You set a message
ceiling, a time window, and optionally restrict the limit to specific client
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
`rejected`.
**Level 2 domain** — optional, on each domain's page. A message ceiling and
window for **every** client IP sending as that domain. When unset, only
level 1 applies for non-privileged senders.
**Level 2 — application (trusted IPs)** — optional override on an application:
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
@@ -381,7 +390,7 @@ but it can look like an open port in external scans.
## Fixed image tag
`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
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
+3 -3
View File
@@ -189,9 +189,9 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
}
// SaveRateLimit stores the application-level rate limit. The caller has validated
// the IPs and numbers (security.md); the milter reads the row live, so no reload
// is needed.
// SaveRateLimit stores the application-level trusted-IP override (guide § Rate
// limiting). The caller has validated the IPs and numbers (security.md); the
// milter reads the row live, so no reload is needed.
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeApp,
+3 -3
View File
@@ -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
// IPs and numbers (security.md); the milter reads the row live, so no reload is
// needed.
// numbers (security.md); the milter reads the row live, so no reload is needed.
// Domain limits do not use an IP allowlist.
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeDomain,
RefID: domainID,
AllowedIPs: ips,
AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel
MaxMessages: maxMessages,
WindowSeconds: windowSeconds,
})
+83 -23
View File
@@ -47,6 +47,9 @@ func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, erro
return store.RateLimit{}, false, f.lookupErr
}
rl, ok := f.limits[scope+"|"+ref]
if ok {
rl.Scope = scope
}
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
// register it so the differentiated check applies.
// limitIP is the client IP rate-limit tests connect from.
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}
}
@@ -212,7 +218,7 @@ func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response {
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
rec := &fakeRecorder{
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
}
@@ -227,7 +233,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
rec := &fakeRecorder{
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
}
@@ -239,7 +245,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
func TestRateLimitAllowsUnderLimit(t *testing.T) {
rec := &fakeRecorder{
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
}
@@ -251,25 +257,76 @@ func TestRateLimitAllowsUnderLimit(t *testing.T) {
}
}
func TestRateLimitIgnoresUnregisteredIP(t *testing.T) {
func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
rec := &fakeRecorder{
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},
}
// The sender's IP is not in the domain's registered set, so level-2 does not
// apply even though the count is huge (level-1 anvil would still cover it).
// Domain ceilings apply to every client IP; leftover AllowedIPs on the row
// 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 {
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) {
rec := &fakeRecorder{
// IP registered but no ceiling/window: an inert draft, must not enforce.
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},
}
@@ -288,7 +345,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
store.RateLimitScopeDomain + "|example.com": domainLimit(),
},
countErr: errors.New("db down"),
}
@@ -300,7 +357,7 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) {
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
rec := &fakeRecorder{
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},
}
@@ -333,7 +390,7 @@ func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) (
func limitedRecorder(count int64) *fakeRecorder {
return &fakeRecorder{
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},
}
@@ -395,17 +452,17 @@ func TestReservationReleasedOnAbort(t *testing.T) {
}
}
// A refused message must not leave the slots it claimed for the limits checked
// before the one that tripped, or every refusal would tighten the ceiling.
func TestRefusalReleasesEarlierReservation(t *testing.T) {
// A trusted app at its ceiling refuses without touching the domain counter;
// no domain reservation should linger after the refusal.
func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
store.RateLimitScopeDomain + "|example.com": domainLimit(),
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room
store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling
store.RateLimitScopeDomain + "|example.com": 0,
store.RateLimitScopeApp + "|app1": 5, // app at ceiling
},
}
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 {
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
+36 -32
View File
@@ -8,9 +8,11 @@ import (
)
// overLimit reports whether the message currently being received should be
// refused under a level-2 differentiated limit (guide § Rate limiting). It
// checks the domain-level and application-level limits in turn; either being
// exceeded is enough to refuse.
// refused under a level-2 differentiated limit (guide § Rate limiting).
//
// 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
// 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 == "" {
return false // no client IP to key on; level-2 does not apply
}
checks := []struct{ scope, ref string }{
{store.RateLimitScopeDomain, domainOf(s.from)},
{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 s.login != "" {
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
if err != nil {
log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err)
continue
log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
} 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
}
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(c.scope, c.ref, since)
n, err := s.rec.CountMessages(scope, ref, since)
if err != nil {
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
continue
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err)
return false
}
key := c.scope + "|" + c.ref
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",
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)
}
scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
return true
}
taken = append(taken, s.flight.reserve(key))
}
s.reserved = append(s.reserved, taken...)
s.reserved = append(s.reserved, s.flight.reserve(key))
return false
}
+24 -20
View File
@@ -18,36 +18,40 @@ const (
)
// 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
// window, attached to a domain or an application. It is enforced in the
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the
// IP backstop that always applies even when this is absent or the milter is
// down.
// a message ceiling over a sliding window, attached to a domain or an
// application. It is enforced in the journal-milter; level 1 (Postfix anvil,
// architecture.md § Mail path) is the IP backstop that always applies even
// when this is absent or the milter is down.
//
// Both the IP binding and the ceiling are optional in the schema, but a limit
// is only enforced when it is Active(): the design deliberately allows an
// admin to leave the IP binding empty for apps that send from changing IPs, in
// which case only level 1 protects them (guide § Rate limiting).
// Domain limits apply to every client IP once max and window are set. Application
// limits additionally require AllowedIPs: those trusted addresses get the app
// ceiling (above the domain) and skip the domain check; other IPs stay under
// the domain limit or level 1 alone (guide § Rate limiting).
type RateLimit struct {
Scope string
RefID int64
AllowedIPs []string // canonical client IPs this limit applies to
AllowedIPs []string // trusted client IPs for an application override
MaxMessages int
WindowSeconds int
}
// Active reports whether the limit is fully configured and should be enforced.
// A missing IP binding, ceiling or window leaves the differentiated limit
// inert (guide § Rate limiting): the IP binding is what scopes the limit to a
// known sender.
// Domain: max and window only. Application: also needs at least one trusted IP
// (the privilege that raises the ceiling above the domain).
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
// comparison parses both sides so equivalent textual forms of the same address
// match; a client IP outside the list means the differentiated limit does not
// apply to it (level 1 still does).
// AllowsIP reports whether ip is one of the application's trusted client IPs.
// Used only for application overrides; domain limits do not consult this list.
// Equivalent textual forms of the same address match.
func (r RateLimit) AllowsIP(ip string) bool {
c := net.ParseIP(ip)
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
// (an IP-only draft) by leaving the corresponding field zero, which makes the
// limit inert via Active().
// by leaving the corresponding field zero, which makes the limit inert via
// Active() until max and window are both set.
func scanRateLimit(r scanRow) (RateLimit, error) {
var (
ips sql.NullString
+17 -9
View File
@@ -152,27 +152,35 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
inactive := []RateLimit{
{},
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
{MaxMessages: 5, WindowSeconds: 60}, // no IPs
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{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 {
if rl.Active() {
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}
if !active.Active() {
t.Fatalf("should be active: %+v", active)
domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60}
if !domainActive.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")
}
// 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")
}
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")
}
}
+4
View File
@@ -24,6 +24,10 @@ type Config struct {
TLSCertFile string
OpenDKIMSocket string
JournalSocket string
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
// and used to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int
RateLimitWindowSeconds int
}
// Handlers holds dependencies for authenticated panel routes.
@@ -59,6 +59,8 @@ func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
data["Error"] = formErr
data["Flash"] = accountFlash(r)
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
h.view.Render(w, status, "settings", data)
}
+3 -1
View File
@@ -165,9 +165,11 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
data["ExportErr"] = view.ExportErr
data["MinPwLen"] = validate.MinSecretFilePasswordLen
data["DomainHasRL"] = domainRLok && domainRL.Active()
data["DomainRLIPs"] = strings.Join(domainRL.AllowedIPs, "\n")
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
data["DomainRLMaxNum"] = domainRL.MaxMessages
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
h.view.Render(w, status, "domain_detail", data)
}
+61 -5
View File
@@ -19,24 +19,73 @@ type rateLimitInput struct {
windowSeconds int
}
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
func (h *Handlers) l1Messages() int {
if h.cfg.RateLimitMessagesPerIP > 0 {
return h.cfg.RateLimitMessagesPerIP
}
return 100
}
func (h *Handlers) l1Window() int {
if h.cfg.RateLimitWindowSeconds > 0 {
return h.cfg.RateLimitWindowSeconds
}
return defaultRateLimitWindowSeconds
}
func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
}
if r.PostFormValue("clear") != "" {
return rateLimitInput{clear: true}, nil
}
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
if rawMax == "" {
return rateLimitInput{clear: true}, nil
}
maxMessages, err := parsePositiveInt(rawMax, 0)
if err != nil || maxMessages <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
}
if maxMessages > l1Max {
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
}
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
if err != nil || windowSeconds <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
}
return rateLimitInput{maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
}
func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive bool) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
}
if r.PostFormValue("clear") != "" {
return rateLimitInput{clear: true}, nil
}
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
if rawMax == "" {
return rateLimitInput{clear: true}, nil
}
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
if err != nil {
return rateLimitInput{}, err
}
if len(ips) == 0 {
return rateLimitInput{clear: true}, nil
return rateLimitInput{}, fmt.Errorf("enter at least one trusted client IP for an application override")
}
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
maxMessages, err := parsePositiveInt(rawMax, 0)
if err != nil || maxMessages <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
}
if maxMessages > l1Max {
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
}
if domainActive && maxMessages <= domainMax {
return rateLimitInput{}, fmt.Errorf("application override must be greater than the domain limit (%d)", domainMax)
}
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
if err != nil || windowSeconds <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
@@ -77,7 +126,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
in, err := parseRateLimitForm(r)
in, err := parseDomainRateLimitForm(r, h.l1Messages())
if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
@@ -103,7 +152,14 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
in, err := parseRateLimitForm(r)
domainRL, domainOK, err := h.domains.RateLimit(d.ID)
if err != nil {
logf("panel: domain %d: rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
domainActive := domainOK && domainRL.Active()
in, err := parseAppRateLimitForm(r, h.l1Messages(), domainRL.MaxMessages, domainActive)
if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
@@ -0,0 +1,93 @@
package handlers
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestParseDomainRateLimitForm(t *testing.T) {
t.Parallel()
form := func(vals url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
in, err := parseDomainRateLimitForm(form(url.Values{
"max_messages": {"50"},
"window_seconds": {"3600"},
}), 100)
if err != nil || in.clear || in.maxMessages != 50 || in.windowSeconds != 3600 || len(in.ips) != 0 {
t.Fatalf("valid domain = %+v err=%v", in, err)
}
in, err = parseDomainRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
if err != nil || !in.clear {
t.Fatalf("empty max should clear: %+v err=%v", in, err)
}
_, err = parseDomainRateLimitForm(form(url.Values{
"max_messages": {"150"},
"window_seconds": {"3600"},
}), 100)
if err == nil || !strings.Contains(err.Error(), "level-1") {
t.Fatalf("over L1 want error, got %v", err)
}
}
func TestParseAppRateLimitForm(t *testing.T) {
t.Parallel()
form := func(vals url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
in, err := parseAppRateLimitForm(form(url.Values{
"allowed_ips": {"203.0.113.10"},
"max_messages": {"80"},
"window_seconds": {"3600"},
}), 100, 40, true)
if err != nil || in.maxMessages != 80 || len(in.ips) != 1 {
t.Fatalf("valid app override = %+v err=%v", in, err)
}
_, err = parseAppRateLimitForm(form(url.Values{
"max_messages": {"80"},
"window_seconds": {"3600"},
}), 100, 40, true)
if err == nil || !strings.Contains(err.Error(), "trusted client IP") {
t.Fatalf("missing IPs want error, got %v", err)
}
_, err = parseAppRateLimitForm(form(url.Values{
"allowed_ips": {"203.0.113.10"},
"max_messages": {"40"},
"window_seconds": {"3600"},
}), 100, 40, true)
if err == nil || !strings.Contains(err.Error(), "greater than the domain") {
t.Fatalf("app <= domain want error, got %v", err)
}
_, err = parseAppRateLimitForm(form(url.Values{
"allowed_ips": {"203.0.113.10"},
"max_messages": {"150"},
"window_seconds": {"3600"},
}), 100, 0, false)
if err == nil || !strings.Contains(err.Error(), "level-1") {
t.Fatalf("over L1 want error, got %v", err)
}
// No domain limit: any app ceiling ≤ L1 is fine.
in, err = parseAppRateLimitForm(form(url.Values{
"allowed_ips": {"203.0.113.10"},
"max_messages": {"50"},
"window_seconds": {"3600"},
}), 100, 0, false)
if err != nil || in.maxMessages != 50 {
t.Fatalf("app without domain = %+v err=%v", in, err)
}
}
+27 -2
View File
@@ -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
two lines. */
.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 .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
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 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
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
@@ -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,
.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 {
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
}
+3 -3
View File
@@ -8,14 +8,14 @@
<h2>Add a sending domain</h2>
<form method="post" action="/domains">
<label for="name">Domain</label>
<div class="input-row">
<input id="name" name="name" type="text" placeholder="example.com"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormName}}" autofocus required>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<button type="submit">Add domain</button>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
</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>
{{end}}
+120 -42
View File
@@ -37,39 +37,93 @@
<div class="check-cols">
<div class="check-col">
<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"}}
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{end}}
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>{{end}}
</div>
<div class="check-col">
<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"}}
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{end}}
<p class="muted">Shallow check: literal address only, no <code>include:</code> /
<code>redirect=</code>.</p>
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>{{end}}
</div>
<div class="check-col">
<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>
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>{{end}}
<div class="field-pair host-type">
<div>
<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 class="check-col">
{{if .DNS.DMARCReportAuth.Status}}
<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>
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
{{end}}</span>{{end}}
{{else}}
<label>Report authorization</label>
<p class="muted">Not required (no external <code>rua=</code>).</p>
@@ -83,17 +137,23 @@
</div>
<div class="split">
<div class="card" id="dkim">
<h2>DKIM record</h2>
<div class="card" id="dkim-spf">
<h2>DKIM and SPF records</h2>
<p class="check-col-title">DKIM</p>
<div class="field-pair host-type">
<div>
<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>
<label>Value</label>
<div class="code-row">
@@ -103,20 +163,21 @@
<p class="muted">Not a secret. Signed with selector
<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>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<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>
<label>Value</label>
<div class="code-row">
@@ -126,16 +187,24 @@
<p class="muted">Merge into an existing SPF if the domain already has one —
do not publish a second record.</p>
</div>
<p class="check-col-title">DMARC</p>
<div class="card" id="dmarc">
<h2>DMARC record</h2>
<div class="field-pair host-type">
<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>
<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">
@@ -151,14 +220,19 @@
{{if .NeedsReportAuth}}
<p class="check-col-title">Report authorization</p>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<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>
<label>Value</label>
<div class="code-row">
@@ -174,8 +248,8 @@
</div>
<div class="split">
<div class="card" id="settings">
<h2>Sending server settings</h2>
<div class="card" id="connection">
<h2>Connection settings</h2>
<p class="muted">Same for every domain. Authenticate with an application
login from below.</p>
@@ -193,8 +267,8 @@
create or regenerate.</p>
</div>
{{/* Create form beside settings, mirroring "Add a sending domain" above the
domains list. */}}
{{/* Create form beside connection settings, mirroring "Add a sending domain"
above the domains list. */}}
<div class="card" id="add-application">
<h2>Add an application</h2>
<form method="post" action="/domains/{{.Domain.ID}}/applications">
@@ -270,16 +344,23 @@
</form>
</div>
<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&nbsp;1:
{{$.L1Messages}} / {{$.L1Window}}s —
<a href="/settings">Settings</a>).
{{if $.DomainHasRL}}Domain ceiling: {{$.DomainRLMaxNum}}.{{else}}No domain ceiling (level&nbsp;1 only for other IPs).{{end}}</p>
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<label>Expected client IPs (one per line or comma-separated)</label>
<label>Trusted client IPs (required; one per line or comma-separated)</label>
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
<p class="muted">The limit counts only connections from these
IPs. Leave empty to leave the limit inactive.</p>
<p class="muted">Only these IPs use the application ceiling and
skip the domain limit. Other IPs stay under the domain (or
level&nbsp;1).</p>
<div class="field-pair">
<div>
<label>Message limit</label>
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
<input name="max_messages" type="number" min="1" max="{{$.L1Messages}}"
value="{{.MaxText}}" placeholder="{{$.L1Messages}}">
</div>
<div>
<label>Window (seconds)</label>
@@ -293,7 +374,7 @@
<button type="submit" form="rl-{{.ID}}">Save limit</button>
{{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.">
data-confirm="Remove the rate limit for {{.Login}}? The domain limit (or level 1) will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
@@ -340,21 +421,18 @@
<div class="check-col">
<p class="check-col-title">Optional level-2 sending rate limit</p>
<p class="muted">Empty IP list = inactive (level-1 only). Status:
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive{{end}}.</p>
<p class="muted">Applies to every client IP on this domain. Status:
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level&nbsp;1 only){{end}}.
Level&nbsp;1 backstop: {{.L1Messages}} messages / {{.L1Window}}s
(<a href="/settings">Settings</a>). Leave the message limit empty to
use level&nbsp;1 only.</p>
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
<textarea id="d_ips" name="allowed_ips" rows="2"
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
<p class="muted">The limit counts only connections from these IPs.
Leave empty to leave the limit inactive.</p>
<div class="field-pair">
<div>
<label for="d_max">Message limit</label>
<input id="d_max" name="max_messages" type="number" min="1"
value="{{.DomainRLMax}}" placeholder="1000">
<input id="d_max" name="max_messages" type="number" min="1" max="{{.L1Messages}}"
value="{{.DomainRLMax}}" placeholder="{{.L1Messages}}">
</div>
<div>
<label for="d_win">Window (seconds)</label>
+22
View File
@@ -102,4 +102,26 @@ this one stays signed in.</p>
this one stays signed in.</p>
</div>
{{end}}
<div class="card" id="rate-limits">
<h2>Sending rate limits</h2>
<p class="muted">Configured in <code>.env</code> / Compose; restart the
container to change level&nbsp;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&nbsp;1 applies. Must be ≤ level&nbsp;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&nbsp;1). Those IPs
skip the domain check; everyone else stays under the domain (or level&nbsp;1).</p>
</div>
{{end}}
+33 -11
View File
@@ -207,7 +207,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
}
// 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
// application Edit panel) use .check-cols. Losing a row silently stacks again.
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")
}
for _, id := range []string{
`id="dkim"`, `id="dns-status"`, `id="spf-dmarc"`,
`id="settings"`, `id="add-application"`, `id="applications"`,
`id="dkim-spf"`, `id="dns-status"`, `id="dmarc"`,
`id="connection"`, `id="add-application"`, `id="applications"`,
`id="domain-settings"`, `id="export"`, `id="danger"`,
} {
if !strings.Contains(src, id) {
@@ -241,18 +241,40 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
if strings.Contains(src, `id="rate-limit"`) {
t.Error("domain rate limit should live inside domain-settings, not its own card")
}
if strings.Contains(src, `id="spf"`) && !strings.Contains(src, `id="spf-dmarc"`) {
t.Error("standalone SPF card should be merged into spf-dmarc")
if strings.Contains(src, `id="d_ips"`) {
t.Error("domain rate limit must not ask for client IPs")
}
if !strings.Contains(src, "level&nbsp;1") {
t.Error("domain rate limit should mention the level-1 backstop")
}
if !strings.Contains(src, "Trusted client IPs") {
t.Error("application override should ask for trusted client IPs")
}
if strings.Contains(src, `id="spf-dmarc"`) {
t.Error("SPF should sit with DKIM, not with DMARC")
}
}
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)
}
if regexp.MustCompile(`id="dmarc"`).MatchString(src) {
t.Error("standalone DMARC card should be merged into spf-dmarc")
}
}
// Drill-down pages carry an up-link directly under the heading and above the
// cards. A link at the bottom of a form is easy to miss and drifts from the
// rest of the panel, so the shared back_link template is mandatory on those
// pages and TestDrillDownPagesPlaceBackLinkAboveContent guards its position.
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
drillDown := map[string]bool{
"user_form.html": true,
+7
View File
@@ -70,6 +70,11 @@ type Config struct {
// checks must not go through the system resolver — see dnscheck's
// externalResolver — so this is how a closed network points them at its own.
DNSResolvers []string
// RateLimitMessagesPerIP and RateLimitWindowSeconds are the level-1
// Postfix anvil backstop (env RATE_LIMIT_*), mirrored into the panel for
// display and to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int
RateLimitWindowSeconds int
}
// Server is the panel HTTP application.
@@ -105,6 +110,8 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
TLSCertFile: cfg.TLSCertFile,
OpenDKIMSocket: cfg.OpenDKIMSocket,
JournalSocket: cfg.JournalSocket,
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
return &Server{cfg: cfg, auth: a, handlers: h}, nil
}
+9 -6
View File
@@ -194,15 +194,18 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a
return appLogin, password, nil
}
// setRateLimit saves a level-2 differentiated limit (guide § Rate limiting)
// on either a domain (/domains/{id}/ratelimit) or an application
// (/applications/{id}/ratelimit).
// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
// (/applications/{id}/ratelimit). allowedIP is required for the trusted-IP
// override; domain ceilings are posted without IPs.
func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error {
resp, body, err := c.postForm(path, url.Values{
"allowed_ips": {allowedIP},
vals := url.Values{
"max_messages": {fmt.Sprintf("%d", maxMessages)},
"window_seconds": {fmt.Sprintf("%d", windowSeconds)},
})
}
if allowedIP != "" {
vals.Set("allowed_ips", allowedIP)
}
resp, body, err := c.postForm(path, vals)
if err != nil {
return err
}