release: 1.9.0
test / test (push) Waiting to run

Application client IP allow-list restricts which addresses may submit as a SASL login; level-2 rate limits override the domain ceiling per application (higher or lower, capped at L1). Migration 0009, authips form, milter enforcement, export/import, and operator docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 23:51:41 +03:00
parent a8ded7ecc8
commit b4a9b93cf2
26 changed files with 457 additions and 252 deletions
+21
View File
@@ -5,8 +5,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [1.9.0] - 2026-08-18
Application sending controls: client IP allow-list for authorization, and
level-2 rate limits that override the domain ceiling per application.
### Added
- Per-application **client IP allow-list** on the domain page: when enabled,
only listed addresses may authenticate and submit as that application; when
off, any client IP is allowed. Form `POST /applications/{id}/authips`.
- SQLite migration `0009_application_auth_ips.sql` (moves legacy trusted IPs
from `rate_limits` into `applications.auth_allowed_ips`).
### Changed ### Changed
- Application level-2 rate limit **overrides** the domain limit for that login
— the ceiling may be higher or lower than the domain setting (still ≤ level
1). Rate limits are no longer tied to client IPs; enforcement is in the
journal-milter at `MAIL FROM`.
- Domain export/import carries `auth_ip_restrict` / `auth_allowed_ips`; legacy
`allowed_ips` on an application `rate_limit` in export JSON migrate on import.
- Panel help, Settings, and [guide.md](docs/guide.md) describe override semantics
and the IP allow-list boundary.
- [guide.md](docs/guide.md): full-backup section documents all four capture - [guide.md](docs/guide.md): full-backup section documents all four capture
methods (panel, `selfpost-backup`, stopped `tar` of the project directory, methods (panel, `selfpost-backup`, stopped `tar` of the project directory,
stopped `tar` of `./data` only), warns against `tar` on a live container, and stopped `tar` of `./data` only), warns against `tar` on a live container, and
+1 -1
View File
@@ -22,7 +22,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.8.0 image: ghcr.io/mixeme/selfpost:1.9.0
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}"
+13 -11
View File
@@ -772,8 +772,8 @@ Parsed report data lives in SQLite and is included in a
`/domains` lists sending domains and hosts the add-domain form (**global `/domains` lists sending domains and hosts the add-domain form (**global
administrator only**). Domain administrators see only domains assigned to administrator only**). Domain administrators see only domains assigned to
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
applications. Per-domain rate limits (level 2) and trusted-IP application applications. Per-domain rate limits (level 2), per-application limits, and
overrides are configured here — see [Rate limiting — optional client IP allow-lists are configured here — see [Rate limiting —
level 2](#rate-limiting--level-2-domain-and-application). *Export domain* level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
writes a single-domain archive; *Import a domain* on the Backup page reads writes a single-domain archive; *Import a domain* on the Backup page reads
one back in (**global administrator only**) — see [Exporting and importing a single one back in (**global administrator only**) — see [Exporting and importing a single
@@ -837,12 +837,15 @@ returns a 4xx and the refusal is recorded in [Deliveries](#deliveries) as
sending as that domain. When unset, only level 1 applies for non-privileged sending as that domain. When unset, only level 1 applies for non-privileged
senders. senders.
**Level 2 — application (trusted IPs)** — optional override on an **Client IP allow-list (application)** — optional restriction on an
application: list one or more client IPs and a ceiling **strictly above** application: when enabled, list one or more client IPs that may authenticate
the domain limit (still ≤ level 1). Connections from those IPs use the and submit mail as that application. When disabled, any client IP is allowed.
application ceiling and skip the domain check. Other IPs stay under the This is independent of rate limits.
domain limit (or level 1 alone). An application override without trusted
IPs is inactive. **Level 2 — application** — optional override of the domain limit for one
application (≤ level 1). The ceiling may be **higher or lower** than the domain
limit. When active, it replaces the domain limit for that login; when unset,
the domain limit (or level 1 alone) applies.
**Manual and auto mode** — each domain and application limit can be **Manual and auto mode** — each domain and application limit can be
**Manual** (you set the ceiling and window) or **Auto**. Auto derives **Manual** (you set the ceiling and window) or **Auto**. Auto derives
@@ -853,8 +856,7 @@ domain page; level-1 refusals are not in the send log, so totals
under-count strict IP limits. When retention is below 30 days, statistics under-count strict IP limits. When retention is below 30 days, statistics
use `min(30, retention)` days. With no traffic in the window, auto stays use `min(30, retention)` days. With no traffic in the window, auto stays
inactive until messages are sent. Auto limits are recalculated every six inactive until messages are sent. Auto limits are recalculated every six
hours and on demand via **Recalculate now**. Application auto ceilings stay hours and on demand via **Recalculate now**.
strictly above an active domain limit when possible.
**Level 2 is best-effort, not a guarantee.** It runs inside the **Level 2 is best-effort, not a guarantee.** It runs inside the
journal-milter and is deliberately fail-open: if the rate-limit lookup hits journal-milter and is deliberately fail-open: if the rate-limit lookup hits
@@ -889,7 +891,7 @@ default until it is changed there.
Domain page → *Export domain* to write the file, *Backup* → *Import a Domain page → *Export domain* to write the file, *Backup* → *Import a
domain* to read it back in. This moves one domain — its DKIM key, its domain* to read it back in. This moves one domain — its DKIM key, its
applications' **working** SASL passwords, and configured **rate limits** applications' **working** SASL passwords, and configured **rate limits**
(mode, ceilings, multipliers, trusted IPs) — to a different SelfPost (mode, ceilings, multipliers) and client IP allow-lists — to a different SelfPost
instance without regenerating anything, so DNS (the DKIM TXT record) instance without regenerating anything, so DNS (the DKIM TXT record)
doesn't need to change. Unlike a full restore (see [Full backup and doesn't need to change. Unlike a full restore (see [Full backup and
restore](#full-backup-and-restore)), this works across different restore](#full-backup-and-restore)), this works across different
+10 -3
View File
@@ -199,12 +199,13 @@ 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 trusted-IP override (guide § Rate // SaveRateLimit stores the application-level rate limit (guide § Rate
// limiting). The caller has validated the IPs and numbers (security.md); the // limiting). The caller has validated the numbers (security.md); the milter
// milter reads the row live, so no reload is needed. // reads the row live, so no reload is needed.
func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error { func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error {
rl.Scope = store.RateLimitScopeApp rl.Scope = store.RateLimitScopeApp
rl.RefID = appID rl.RefID = appID
rl.AllowedIPs = nil
return s.store.SetRateLimit(rl) return s.store.SetRateLimit(rl)
} }
@@ -214,6 +215,12 @@ func (s *Service) ClearRateLimit(appID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID) return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
} }
// UpdateAuthIPs sets client IP restriction for an application (guide § Rate
// limiting — authorization). The caller has validated the IPs (security.md).
func (s *Service) UpdateAuthIPs(appID int64, restrict bool, ips []string) error {
return s.store.UpdateApplicationAuthIPs(appID, restrict, ips)
}
// PurgeDomainSASL removes the SASL accounts of every application bound to a // PurgeDomainSASL removes the SASL accounts of every application bound to a
// domain. It must be called before the domain's registry rows are cascade- // domain. It must be called before the domain's registry rows are cascade-
// deleted, while the logins are still known (product.md). The registry rows and // deleted, while the logins are still known (product.md). The registry rows and
+34 -16
View File
@@ -40,11 +40,13 @@ type RateLimitExport struct {
// AppExport is one application within a DomainExport. // AppExport is one application within a DomainExport.
type AppExport struct { type AppExport struct {
Login string `json:"login"` Login string `json:"login"`
AddressMode string `json:"address_mode"` AddressMode string `json:"address_mode"`
Addresses []string `json:"addresses,omitempty"` // list mode only Addresses []string `json:"addresses,omitempty"` // list mode only
Password string `json:"password"` Password string `json:"password"`
RateLimit *RateLimitExport `json:"rate_limit,omitempty"` AuthIPRestrict bool `json:"auth_ip_restrict,omitempty"`
AuthAllowedIPs []string `json:"auth_allowed_ips,omitempty"`
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
} }
// Export builds the transferable representation of a domain: its DKIM key, its // Export builds the transferable representation of a domain: its DKIM key, its
@@ -85,10 +87,12 @@ func (s *Service) Export(id int64) (DomainExport, error) {
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err) return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
} }
appExp := AppExport{ appExp := AppExport{
Login: a.Login, Login: a.Login,
AddressMode: a.AddressMode, AddressMode: a.AddressMode,
Addresses: a.Addresses, Addresses: a.Addresses,
Password: password, Password: password,
AuthIPRestrict: a.AuthIPRestrict,
AuthAllowedIPs: a.AuthAllowedIPs,
} }
if rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeApp, a.ID); err == nil && ok { if rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeApp, a.ID); err == nil && ok {
appExp.RateLimit = exportRateLimit(rl) appExp.RateLimit = exportRateLimit(rl)
@@ -153,15 +157,31 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
s.importRollback(d.ID) s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err) return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
} }
if a.RateLimit != nil { restrict, ips := a.AuthIPRestrict, a.AuthAllowedIPs
// Legacy exports stored client IPs on the rate-limit row as trusted IPs.
if a.RateLimit != nil && len(a.RateLimit.AllowedIPs) > 0 {
restrict = true
ips = a.RateLimit.AllowedIPs
}
needAuthIPs := restrict || len(ips) > 0
needRateLimit := a.RateLimit != nil
if needAuthIPs || needRateLimit {
app, err := s.store.GetApplicationByLogin(a.Login) app, err := s.store.GetApplicationByLogin(a.Login)
if err != nil { if err != nil {
s.importRollback(d.ID) s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import rate limit for %q: %w", a.Login, err) return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
} }
if err := s.importRateLimit(store.RateLimitScopeApp, app.ID, *a.RateLimit); err != nil { if needAuthIPs {
s.importRollback(d.ID) if err := s.store.UpdateApplicationAuthIPs(app.ID, restrict, ips); err != nil {
return store.Domain{}, fmt.Errorf("import rate limit for %q: %w", a.Login, err) s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import auth IPs for %q: %w", a.Login, err)
}
}
if needRateLimit {
if err := s.importRateLimit(store.RateLimitScopeApp, app.ID, *a.RateLimit); err != nil {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import rate limit for %q: %w", a.Login, err)
}
} }
} }
} }
@@ -191,7 +211,6 @@ func exportRateLimit(rl store.RateLimit) *RateLimitExport {
MaxMessages: rl.MaxMessages, MaxMessages: rl.MaxMessages,
WindowSeconds: rl.WindowSeconds, WindowSeconds: rl.WindowSeconds,
AutoMultiplier: rl.AutoMultiplier, AutoMultiplier: rl.AutoMultiplier,
AllowedIPs: rl.AllowedIPs,
} }
return exp return exp
} }
@@ -208,7 +227,6 @@ func (s *Service) importRateLimit(scope string, refID int64, exp RateLimitExport
MaxMessages: exp.MaxMessages, MaxMessages: exp.MaxMessages,
WindowSeconds: exp.WindowSeconds, WindowSeconds: exp.WindowSeconds,
AutoMultiplier: exp.AutoMultiplier, AutoMultiplier: exp.AutoMultiplier,
AllowedIPs: exp.AllowedIPs,
} }
if mode == store.RateLimitModeManual && rl.MaxMessages <= 0 && rl.WindowSeconds <= 0 { if mode == store.RateLimitModeManual && rl.MaxMessages <= 0 && rl.WindowSeconds <= 0 {
return nil return nil
+31
View File
@@ -0,0 +1,31 @@
package milter
import (
"errors"
"log"
"github.com/mixeme/selfpost/internal/store"
)
// authIPAllowed reports whether the authenticated application may submit from
// the connecting client IP. When the application has no IP restriction, or the
// client IP is not known, the check passes. Store errors are fail-open (see
// overLimit).
func (s *session) authIPAllowed() bool {
if s.login == "" || s.clientIP == "" {
return true
}
a, err := s.rec.ApplicationByLogin(s.login)
if err != nil {
if errors.Is(err, store.ErrApplicationNotFound) {
return true
}
log.Printf("journal-milter: auth IP lookup application %q: %v (fail-open)", s.login, err)
return true
}
if a.AllowsAuthFromIP(s.clientIP) {
return true
}
log.Printf("journal-milter: application %q refused from %s — client IP not allowed", s.login, s.clientIP)
return false
}
+5
View File
@@ -35,6 +35,7 @@ type Store interface {
InsertRejected(e store.SendLogEntry) error InsertRejected(e store.SendLogEntry) error
RateLimit(scope, ref string) (store.RateLimit, bool, error) RateLimit(scope, ref string) (store.RateLimit, bool, error)
CountMessages(scope, ref string, since time.Time) (int64, error) CountMessages(scope, ref string, since time.Time) (int64, error)
ApplicationByLogin(login string) (store.Application, error)
} }
// session accumulates the fields of one message as the milter callbacks fire. // session accumulates the fields of one message as the milter callbacks fire.
@@ -84,6 +85,10 @@ func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, er
s.login = macro(m, "auth_authen") s.login = macro(m, "auth_authen")
s.rcpts = nil s.rcpts = nil
s.subject = "" s.subject = ""
if !s.authIPAllowed() {
s.recordRejected()
return milter.RespTempFail, nil
}
if s.overLimit() { if s.overLimit() {
s.recordRejected() s.recordRejected()
return milter.RespTempFail, nil return milter.RespTempFail, nil
+52 -31
View File
@@ -25,10 +25,12 @@ type fakeRecorder struct {
fail bool fail bool
// limits, keyed by "scope|ref", drive the level-2 rate-limit tests. counts // limits, keyed by "scope|ref", drive the level-2 rate-limit tests. counts
// gives the recent-message count returned for a "scope|ref". lookupErr and // gives the recent-message count returned for a "scope|ref". apps supplies
// countErr force the store errors that must fail open. // application rows for client-IP authorization tests. lookupErr and countErr
// force the store errors that must fail open.
limits map[string]store.RateLimit limits map[string]store.RateLimit
counts map[string]int64 counts map[string]int64
apps map[string]store.Application
lookupErr error lookupErr error
countErr error countErr error
@@ -75,6 +77,17 @@ func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, err
return f.counts[scope+"|"+ref], nil return f.counts[scope+"|"+ref], nil
} }
func (f *fakeRecorder) ApplicationByLogin(login string) (store.Application, error) {
if f.lookupErr != nil {
return store.Application{}, f.lookupErr
}
a, ok := f.apps[login]
if !ok {
return store.Application{}, store.ErrApplicationNotFound
}
return a, nil
}
func mods(kv map[string]string) *milter.Modifier { func mods(kv map[string]string) *milter.Modifier {
return &milter.Modifier{Macros: kv} return &milter.Modifier{Macros: kv}
} }
@@ -211,8 +224,8 @@ func domainLimit() store.RateLimit {
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600} return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
} }
func appLimit(ips ...string) store.RateLimit { func appLimit() store.RateLimit {
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600} return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
} }
// mailFrom drives just the connect + MAIL FROM stages and returns the response, // mailFrom drives just the connect + MAIL FROM stages and returns the response,
@@ -248,7 +261,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": appLimit(limitIP), store.RateLimitScopeApp + "|app1": appLimit(),
}, },
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
} }
@@ -286,13 +299,11 @@ func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
} }
} }
func TestRateLimitTrustedAppSkipsDomain(t *testing.T) { func TestRateLimitAppSkipsDomainWhenActive(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600}, store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
store.RateLimitScopeApp + "|app1": { store.RateLimitScopeApp + "|app1": appLimit(),
AllowedIPs: []string{limitIP}, MaxMessages: 10, WindowSeconds: 3600,
},
}, },
counts: map[string]int64{ counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 5, // over domain store.RateLimitScopeDomain + "|example.com": 5, // over domain
@@ -300,41 +311,51 @@ func TestRateLimitTrustedAppSkipsDomain(t *testing.T) {
}, },
} }
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("trusted app under its ceiling = %v, want Continue (domain skipped)", resp) t.Fatalf("app under its ceiling = %v, want Continue (domain not checked)", resp)
} }
} }
func TestRateLimitUnlistedIPHitsDomain(t *testing.T) { func TestAuthIPRestrictBlocksUnlisted(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ apps: map[string]store.Application{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600}, "app1": {
store.RateLimitScopeApp + "|app1": { Login: "app1",
AllowedIPs: []string{"198.51.100.1"}, MaxMessages: 100, WindowSeconds: 3600, AuthIPRestrict: true,
AuthAllowedIPs: []string{"198.51.100.1"},
}, },
}, },
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 { if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("unlisted IP under domain = %v, want TempFail", resp) t.Fatalf("unlisted IP = %v, want TempFail", resp)
}
if len(rec.rejected) != 1 {
t.Fatalf("want one rejected row, got %+v", rec.rejected)
} }
} }
func TestRateLimitAppWithoutIPsDoesNotPrivilege(t *testing.T) { func TestAuthIPRestrictAllowsListed(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ apps: map[string]store.Application{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600}, "app1": {
store.RateLimitScopeApp + "|app1": {MaxMessages: 100, WindowSeconds: 3600}, // no IPs Login: "app1",
}, AuthIPRestrict: true,
counts: map[string]int64{ AuthAllowedIPs: []string{limitIP},
store.RateLimitScopeDomain + "|example.com": 1, },
store.RateLimitScopeApp + "|app1": 0,
}, },
} }
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail { if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("app without IPs must not skip domain = %v, want TempFail", resp) t.Fatalf("listed IP = %v, want Continue", resp)
}
}
func TestAuthIPRestrictOffAllowsAnyIP(t *testing.T) {
rec := &fakeRecorder{
apps: map[string]store.Application{
"app1": {Login: "app1"},
},
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("restriction off = %v, want Continue", resp)
} }
} }
@@ -525,7 +546,7 @@ func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(), store.RateLimitScopeDomain + "|example.com": domainLimit(),
store.RateLimitScopeApp + "|app1": appLimit(limitIP), store.RateLimitScopeApp + "|app1": appLimit(),
}, },
counts: map[string]int64{ counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 0, store.RateLimitScopeDomain + "|example.com": 0,
+5 -4
View File
@@ -10,9 +10,10 @@ 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). // refused under a level-2 differentiated limit (guide § Rate limiting).
// //
// Trusted application IPs (app limit active and client IP listed) use only the // When an application has an active limit, it overrides the domain limit for
// app ceiling and skip the domain check. Everyone else is under the domain // that login (the ceiling may be higher or lower than the domain). Otherwise
// ceiling when one is configured; otherwise only level 1 applies. // the domain ceiling applies when configured; if neither is set, 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
@@ -33,7 +34,7 @@ func (s *session) overLimit() bool {
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login) rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
if err != nil { if err != nil {
log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err) log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
} else if ok && rl.Active() && rl.AllowsIP(s.clientIP) { } else if ok && rl.Active() {
return s.enforceLimit(store.RateLimitScopeApp, s.login, rl) return s.enforceLimit(store.RateLimitScopeApp, s.login, rl)
} }
} }
+73 -10
View File
@@ -4,6 +4,8 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"net"
"strings"
"time" "time"
) )
@@ -27,12 +29,32 @@ const (
// hashed — so it can be shown exactly once at creation/regeneration // hashed — so it can be shown exactly once at creation/regeneration
// (security.md). Addresses is populated only in 'list' mode. // (security.md). Addresses is populated only in 'list' mode.
type Application struct { type Application struct {
ID int64 ID int64
DomainID int64 DomainID int64
Login string Login string
AddressMode string AddressMode string
CreatedAt time.Time CreatedAt time.Time
Addresses []string Addresses []string
AuthIPRestrict bool // when true, only AuthAllowedIPs may submit as this login
AuthAllowedIPs []string // client IPs permitted when AuthIPRestrict is set
}
// AllowsAuthFromIP reports whether a client at ip may submit mail authenticated
// as this application. When AuthIPRestrict is false, every IP is allowed.
func (a Application) AllowsAuthFromIP(ip string) bool {
if !a.AuthIPRestrict {
return true
}
c := net.ParseIP(ip)
if c == nil {
return false
}
for _, allowed := range a.AuthAllowedIPs {
if p := net.ParseIP(allowed); p != nil && p.Equal(c) {
return true
}
}
return false
} }
// Binding is one sender-address → login pair, as consumed by the // Binding is one sender-address → login pair, as consumed by the
@@ -147,7 +169,7 @@ func normalizedList(mode string, addresses []string) []string {
// ErrApplicationNotFound. // ErrApplicationNotFound.
func (s *Store) GetApplication(id int64) (Application, error) { func (s *Store) GetApplication(id int64) (Application, error) {
row := s.db.QueryRow( row := s.db.QueryRow(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE id = ?", id) "SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips FROM applications WHERE id = ?", id)
a, err := scanApplication(row) a, err := scanApplication(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrApplicationNotFound return Application{}, ErrApplicationNotFound
@@ -166,7 +188,7 @@ func (s *Store) GetApplication(id int64) (Application, error) {
// GetApplicationByLogin returns one application by its globally unique SASL login. // GetApplicationByLogin returns one application by its globally unique SASL login.
func (s *Store) GetApplicationByLogin(login string) (Application, error) { func (s *Store) GetApplicationByLogin(login string) (Application, error) {
row := s.db.QueryRow( row := s.db.QueryRow(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE login = ?", login) "SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips FROM applications WHERE login = ?", login)
a, err := scanApplication(row) a, err := scanApplication(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrApplicationNotFound return Application{}, ErrApplicationNotFound
@@ -182,11 +204,16 @@ func (s *Store) GetApplicationByLogin(login string) (Application, error) {
return a, nil return a, nil
} }
// ApplicationByLogin is the milter-facing alias for GetApplicationByLogin.
func (s *Store) ApplicationByLogin(login string) (Application, error) {
return s.GetApplicationByLogin(login)
}
// ListApplicationsByDomain returns a domain's applications ordered by login, // ListApplicationsByDomain returns a domain's applications ordered by login,
// each with its address list populated (product.md). // each with its address list populated (product.md).
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) { func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE domain_id = ? ORDER BY login", "SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips FROM applications WHERE domain_id = ? ORDER BY login",
domainID) domainID)
if err != nil { if err != nil {
return nil, fmt.Errorf("list applications: %w", err) return nil, fmt.Errorf("list applications: %w", err)
@@ -291,6 +318,31 @@ func (s *Store) ListBindings() ([]Binding, error) {
return out, rows.Err() return out, rows.Err()
} }
// UpdateApplicationAuthIPs sets whether client IP restriction is active for an
// application and, when active, the permitted client addresses. The caller has
// already validated the IPs (security.md). Returns ErrApplicationNotFound when
// the id does not exist.
func (s *Store) UpdateApplicationAuthIPs(id int64, restrict bool, ips []string) error {
if !restrict {
ips = nil
}
res, err := s.db.Exec(
"UPDATE applications SET auth_ip_restrict = ?, auth_allowed_ips = ? WHERE id = ?",
boolToInt(restrict), strings.Join(ips, ","), id,
)
if err != nil {
return fmt.Errorf("update application auth IPs: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update application auth IPs rows: %w", err)
}
if n == 0 {
return ErrApplicationNotFound
}
return nil
}
// DeleteApplication removes an application and its addresses (via cascade), // DeleteApplication removes an application and its addresses (via cascade),
// returning the deleted application so the caller can drop its sasldb2 entry // returning the deleted application so the caller can drop its sasldb2 entry
// (product.md). Returns ErrApplicationNotFound if no such row existed. // (product.md). Returns ErrApplicationNotFound if no such row existed.
@@ -336,10 +388,21 @@ func scanApplication(r scanRow) (Application, error) {
var ( var (
a Application a Application
createdAt string createdAt string
restrict int
ips sql.NullString
) )
if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt); err != nil { if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt, &restrict, &ips); err != nil {
return Application{}, err return Application{}, err
} }
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
a.AuthIPRestrict = restrict != 0
a.AuthAllowedIPs = splitIPs(ips.String)
return a, nil return a, nil
} }
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+40
View File
@@ -187,3 +187,43 @@ func TestListLoginsByDomain(t *testing.T) {
t.Fatalf("logins = %v, want [a b]", logins) t.Fatalf("logins = %v, want [a b]", logins)
} }
} }
func TestApplicationAuthIPs(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
a, err := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
if err != nil {
t.Fatal(err)
}
if a.AuthIPRestrict || len(a.AuthAllowedIPs) != 0 {
t.Fatalf("new app should have no IP restriction: %+v", a)
}
if err := st.UpdateApplicationAuthIPs(a.ID, true, []string{"203.0.113.1", "2001:db8::1"}); err != nil {
t.Fatalf("UpdateApplicationAuthIPs: %v", err)
}
got, err := st.GetApplication(a.ID)
if err != nil {
t.Fatal(err)
}
if !got.AuthIPRestrict || len(got.AuthAllowedIPs) != 2 {
t.Fatalf("after update: %+v", got)
}
if !got.AllowsAuthFromIP("203.0.113.1") || !got.AllowsAuthFromIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
t.Fatal("listed IPs should match")
}
if got.AllowsAuthFromIP("198.51.100.7") {
t.Fatal("unlisted IP must not match")
}
if err := st.UpdateApplicationAuthIPs(a.ID, false, nil); err != nil {
t.Fatal(err)
}
got, _ = st.GetApplication(a.ID)
if got.AuthIPRestrict || len(got.AuthAllowedIPs) != 0 {
t.Fatalf("cleared restriction: %+v", got)
}
if !got.AllowsAuthFromIP("198.51.100.7") {
t.Fatal("restriction off should allow any IP")
}
}
@@ -0,0 +1,22 @@
-- Client IP restriction for application SASL authorization (independent of
-- level-2 rate limits). When enabled, only listed IPs may submit mail as the
-- application; when disabled, any client IP is allowed.
ALTER TABLE applications ADD COLUMN auth_ip_restrict INTEGER NOT NULL DEFAULT 0;
ALTER TABLE applications ADD COLUMN auth_allowed_ips TEXT;
-- Migrate trusted IPs from application rate_limits into auth_allowed_ips.
UPDATE applications
SET auth_ip_restrict = 1,
auth_allowed_ips = (
SELECT allowed_ips FROM rate_limits
WHERE scope = 'application' AND ref_id = applications.id
)
WHERE EXISTS (
SELECT 1 FROM rate_limits
WHERE scope = 'application' AND ref_id = applications.id
AND allowed_ips IS NOT NULL AND TRIM(allowed_ips) != ''
);
UPDATE rate_limits SET allowed_ips = NULL
WHERE scope = 'application' AND allowed_ips IS NOT NULL;
-25
View File
@@ -92,8 +92,6 @@ func (s *Store) recalcAutoRateLimit(rl RateLimit, retentionDays, l1Max, l1Window
} }
var stats SendStats var stats SendStats
var domainMax int
var domainActive bool
switch rl.Scope { switch rl.Scope {
case RateLimitScopeDomain: case RateLimitScopeDomain:
@@ -114,22 +112,11 @@ func (s *Store) recalcAutoRateLimit(rl RateLimit, retentionDays, l1Max, l1Window
if err != nil { if err != nil {
return err return err
} }
domainRL, ok, err := s.GetRateLimit(RateLimitScopeDomain, a.DomainID)
if err != nil {
return err
}
domainActive = ok && domainRL.Active()
if domainActive {
domainMax = domainRL.MaxMessages
}
default: default:
return fmt.Errorf("unknown scope %q", rl.Scope) return fmt.Errorf("unknown scope %q", rl.Scope)
} }
maxMsgs := computeAutoMaxMessages(stats, mult, l1Max) maxMsgs := computeAutoMaxMessages(stats, mult, l1Max)
if rl.Scope == RateLimitScopeApp {
maxMsgs = adjustAppAutoMax(maxMsgs, domainMax, domainActive, l1Max)
}
rl.MaxMessages = maxMsgs rl.MaxMessages = maxMsgs
rl.WindowSeconds = l1Window rl.WindowSeconds = l1Window
@@ -152,15 +139,3 @@ func computeAutoMaxMessages(stats SendStats, multiplier float64, l1Max int) int
return max return max
} }
func adjustAppAutoMax(appMax, domainMax int, domainActive bool, l1Max int) int {
if appMax <= 0 {
return 0
}
if domainActive && appMax <= domainMax {
appMax = domainMax + 1
if appMax > l1Max {
return 0
}
}
return appMax
}
+5 -32
View File
@@ -4,7 +4,6 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"net"
"strings" "strings"
"time" "time"
) )
@@ -36,14 +35,13 @@ const (
// architecture.md § Mail path) is the IP backstop that always applies even // architecture.md § Mail path) is the IP backstop that always applies even
// when this is absent or the milter is down. // when this is absent or the milter is down.
// //
// Domain limits apply to every client IP once max and window are set. Application // Domain and application limits apply once max and window are set. Client IP
// limits additionally require AllowedIPs: those trusted addresses get the app // restriction for an application is stored on the application row, not here
// ceiling (above the domain) and skip the domain check; other IPs stay under // (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 // trusted client IPs for an application override AllowedIPs []string // legacy column; unused for new rows
MaxMessages int MaxMessages int
WindowSeconds int WindowSeconds int
Mode string // manual | auto Mode string // manual | auto
@@ -52,38 +50,13 @@ type RateLimit struct {
} }
// Active reports whether the limit is fully configured and should be enforced. // Active reports whether the limit is fully configured and should be enforced.
// Domain: max and window only. Application: also needs at least one trusted IP
// (the privilege that raises the ceiling above the domain).
// IsAuto reports whether the limit derives max_messages from send statistics. // IsAuto reports whether the limit derives max_messages from send statistics.
func (r RateLimit) IsAuto() bool { func (r RateLimit) IsAuto() bool {
return r.Mode == RateLimitModeAuto return r.Mode == RateLimitModeAuto
} }
func (r RateLimit) Active() bool { func (r RateLimit) Active() bool {
if r.MaxMessages <= 0 || r.WindowSeconds <= 0 { return 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 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 {
return false
}
for _, a := range r.AllowedIPs {
if p := net.ParseIP(a); p != nil && p.Equal(c) {
return true
}
}
return false
} }
// GetRateLimit loads the level-2 limit configured for a domain or application by // GetRateLimit loads the level-2 limit configured for a domain or application by
+17 -30
View File
@@ -150,39 +150,26 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
} }
} }
func TestRateLimitActiveAndAllowsIP(t *testing.T) { func TestRateLimitActive(t *testing.T) {
inactive := []RateLimit{ inactive := []RateLimit{
{}, {},
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling {Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window {Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs {Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
{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)
} }
} }
domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60} active := []RateLimit{
if !domainActive.Active() { {Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60},
t.Fatalf("domain without IPs should be active: %+v", domainActive) {Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60},
} }
appActive := RateLimit{ for i, rl := range active {
Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, if !rl.Active() {
MaxMessages: 5, WindowSeconds: 60, t.Fatalf("case %d: %+v should be active", i, rl)
} }
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 !appActive.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
t.Fatalf("expanded IPv6 form should match")
}
if appActive.AllowsIP("198.51.100.7") || appActive.AllowsIP("not-an-ip") || appActive.AllowsIP("") {
t.Fatalf("unregistered/invalid IPs must not match")
} }
} }
@@ -219,14 +206,14 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("window = %d, want 3600", rl.WindowSeconds) t.Fatalf("window = %d, want 3600", rl.WindowSeconds)
} }
// Domain limit at ceiling; app auto must be strictly above or inactive at L1. // Domain limit at ceiling; app auto is capped at L1 independently.
_ = st.SetRateLimit(RateLimit{ _ = st.SetRateLimit(RateLimit{
Scope: RateLimitScopeDomain, RefID: d.ID, Mode: RateLimitModeManual, Scope: RateLimitScopeDomain, RefID: d.ID, Mode: RateLimitModeManual,
MaxMessages: 100, WindowSeconds: 3600, MaxMessages: 100, WindowSeconds: 3600,
}) })
if err := st.SetRateLimit(RateLimit{ if err := st.SetRateLimit(RateLimit{
Scope: RateLimitScopeApp, RefID: a.ID, Mode: RateLimitModeAuto, Scope: RateLimitScopeApp, RefID: a.ID, Mode: RateLimitModeAuto,
AllowedIPs: []string{"203.0.113.1"}, AutoMultiplier: 2.0, AutoMultiplier: 2.0,
}); err != nil { }); err != nil {
t.Fatalf("SetRateLimit app: %v", err) t.Fatalf("SetRateLimit app: %v", err)
} }
@@ -234,8 +221,8 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("RecalcAutoRateLimit app: %v", err) t.Fatalf("RecalcAutoRateLimit app: %v", err)
} }
appRL, ok, _ := st.GetRateLimit(RateLimitScopeApp, a.ID) appRL, ok, _ := st.GetRateLimit(RateLimitScopeApp, a.ID)
if ok && appRL.Active() { if !ok || !appRL.Active() || appRL.MaxMessages > 100 {
t.Fatalf("app auto at L1 cap with domain at L1 should be inactive: %+v", appRL) t.Fatalf("app auto at L1 cap should still be active: %+v", appRL)
} }
_ = st.SetRateLimit(RateLimit{ _ = st.SetRateLimit(RateLimit{
@@ -246,8 +233,8 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("RecalcAutoRateLimit app: %v", err) t.Fatalf("RecalcAutoRateLimit app: %v", err)
} }
appRL, ok, _ = st.GetRateLimit(RateLimitScopeApp, a.ID) appRL, ok, _ = st.GetRateLimit(RateLimitScopeApp, a.ID)
if !ok || !appRL.Active() || appRL.MaxMessages <= 40 { if !ok || !appRL.Active() {
t.Fatalf("app auto should be above domain 40: %+v", appRL) t.Fatalf("app auto should remain active with domain at 40: %+v", appRL)
} }
// Milter reads the stored ceiling via RateLimit(name/login). // Milter reads the stored ceiling via RateLimit(name/login).
+4 -2
View File
@@ -46,7 +46,7 @@ type detailView struct {
type appRateLimitView struct { type appRateLimitView struct {
store.Application store.Application
HasLimit bool HasLimit bool
IPsText string AuthIPsText string
MaxText string MaxText string
WindowVal string WindowVal string
Mode string Mode string
@@ -116,7 +116,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
appViews = append(appViews, appRateLimitView{ appViews = append(appViews, appRateLimitView{
Application: a, Application: a,
HasLimit: ok && rl.Active(), HasLimit: ok && rl.Active(),
IPsText: strings.Join(rl.AllowedIPs, "\n"), AuthIPsText: strings.Join(a.AuthAllowedIPs, "\n"),
MaxText: intOrBlank(rl.MaxMessages), MaxText: intOrBlank(rl.MaxMessages),
WindowVal: windowOrDefault(rl.WindowSeconds), WindowVal: windowOrDefault(rl.WindowSeconds),
Mode: mode, Mode: mode,
@@ -332,6 +332,8 @@ func detailFlash(r *http.Request) string {
return "Application address mode updated." return "Application address mode updated."
case r.URL.Query().Get("ratelimit") != "": case r.URL.Query().Get("ratelimit") != "":
return "Rate limit updated." return "Rate limit updated."
case r.URL.Query().Get("authips") != "":
return "Client IP restriction updated."
case r.URL.Query().Get("recalculated") != "": case r.URL.Query().Get("recalculated") != "":
return "Auto rate limit recalculated." return "Auto rate limit recalculated."
case r.URL.Query().Get("dmarc") != "": case r.URL.Query().Get("dmarc") != "":
+48 -23
View File
@@ -98,7 +98,7 @@ func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error
return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
} }
func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive bool) (rateLimitInput, error) { func parseAppRateLimitForm(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")
} }
@@ -110,20 +110,12 @@ func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive b
return rateLimitInput{}, err return rateLimitInput{}, err
} }
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
if err != nil {
return rateLimitInput{}, err
}
if len(ips) == 0 {
return rateLimitInput{}, fmt.Errorf("enter at least one trusted client IP for an application override")
}
if mode == store.RateLimitModeAuto { if mode == store.RateLimitModeAuto {
mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier")) mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier"))
if err != nil { if err != nil {
return rateLimitInput{}, err return rateLimitInput{}, err
} }
return rateLimitInput{mode: mode, ips: ips, autoMultiplier: mult}, nil return rateLimitInput{mode: mode, autoMultiplier: mult}, nil
} }
rawMax := strings.TrimSpace(r.PostFormValue("max_messages")) rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
@@ -137,14 +129,29 @@ func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive b
if maxMessages > l1Max { if maxMessages > l1Max {
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", 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")
} }
return rateLimitInput{mode: store.RateLimitModeManual, ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
}
func parseAppAuthIPsForm(r *http.Request) (bool, []string, error) {
if err := r.ParseForm(); err != nil {
return false, nil, fmt.Errorf("invalid form submission")
}
restrict := r.PostFormValue("auth_ip_restrict") != ""
if !restrict {
return false, nil, nil
}
ips, err := parseIPList(r.PostFormValue("auth_allowed_ips"))
if err != nil {
return false, nil, err
}
if len(ips) == 0 {
return false, nil, fmt.Errorf("enter at least one client IP when the allow-list is enabled")
}
return true, ips, nil
} }
func parseIPList(raw string) ([]string, error) { func parseIPList(raw string) ([]string, error) {
@@ -206,14 +213,7 @@ 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
} }
domainRL, domainOK, err := h.domains.RateLimit(d.ID) in, err := parseAppRateLimitForm(r, h.l1Messages())
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,
@@ -229,6 +229,32 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
} }
func (h *Handlers) HandleAppAuthIPs(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
d, err := h.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
restrict, ips, err := parseAppAuthIPsForm(r)
if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
})
return
}
if err := h.apps.UpdateAuthIPs(a.ID, restrict, ips); err != nil {
logf("panel: application %d: save auth IPs: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?authips=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r) d, ok := h.lookupDomain(w, r)
if !ok { if !ok {
@@ -294,7 +320,6 @@ func (h *Handlers) applyAppRateLimit(in rateLimitInput, appID int64) error {
rl := store.RateLimit{ rl := store.RateLimit{
Scope: store.RateLimitScopeApp, Scope: store.RateLimitScopeApp,
RefID: appID, RefID: appID,
AllowedIPs: in.ips,
Mode: in.mode, Mode: in.mode,
MaxMessages: in.maxMessages, MaxMessages: in.maxMessages,
WindowSeconds: in.windowSeconds, WindowSeconds: in.windowSeconds,
@@ -50,52 +50,52 @@ func TestParseAppRateLimitForm(t *testing.T) {
in, err := parseAppRateLimitForm(form(url.Values{ in, err := parseAppRateLimitForm(form(url.Values{
"mode": {"manual"}, "mode": {"manual"},
"allowed_ips": {"203.0.113.10"},
"max_messages": {"80"}, "max_messages": {"80"},
"window_seconds": {"3600"}, "window_seconds": {"3600"},
}), 100, 40, true) }), 100)
if err != nil || in.maxMessages != 80 || len(in.ips) != 1 { if err != nil || in.maxMessages != 80 {
t.Fatalf("valid app override = %+v err=%v", in, err) t.Fatalf("valid app limit = %+v err=%v", in, err)
}
in, err = parseAppRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
if err != nil || !in.clear {
t.Fatalf("empty max should clear: %+v err=%v", in, err)
} }
_, err = parseAppRateLimitForm(form(url.Values{ _, err = parseAppRateLimitForm(form(url.Values{
"mode": {"manual"}, "mode": {"manual"},
"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{
"mode": {"manual"},
"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{
"mode": {"manual"},
"allowed_ips": {"203.0.113.10"},
"max_messages": {"150"}, "max_messages": {"150"},
"window_seconds": {"3600"}, "window_seconds": {"3600"},
}), 100, 0, false) }), 100)
if err == nil || !strings.Contains(err.Error(), "level-1") { if err == nil || !strings.Contains(err.Error(), "level-1") {
t.Fatalf("over L1 want error, got %v", err) t.Fatalf("over L1 want error, got %v", err)
} }
}
// No domain limit: any app ceiling ≤ L1 is fine. func TestParseAppAuthIPsForm(t *testing.T) {
in, err = parseAppRateLimitForm(form(url.Values{ t.Parallel()
"mode": {"manual"}, form := func(vals url.Values) *http.Request {
"allowed_ips": {"203.0.113.10"}, r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
"max_messages": {"50"}, r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
"window_seconds": {"3600"}, return r
}), 100, 0, false) }
if err != nil || in.maxMessages != 50 {
t.Fatalf("app without domain = %+v err=%v", in, err) restrict, ips, err := parseAppAuthIPsForm(form(url.Values{
"auth_ip_restrict": {"1"},
"auth_allowed_ips": {"203.0.113.10"},
}))
if err != nil || !restrict || len(ips) != 1 {
t.Fatalf("enabled with IP = restrict=%v ips=%v err=%v", restrict, ips, err)
}
restrict, ips, err = parseAppAuthIPsForm(form(url.Values{}))
if err != nil || restrict || ips != nil {
t.Fatalf("disabled = restrict=%v ips=%v err=%v", restrict, ips, err)
}
_, _, err = parseAppAuthIPsForm(form(url.Values{"auth_ip_restrict": {"1"}}))
if err == nil || !strings.Contains(err.Error(), "at least one client IP") {
t.Fatalf("enabled without IPs want error, got %v", err)
} }
} }
+2 -2
View File
@@ -369,12 +369,12 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
.app .actions > .panel .check-col > .muted { margin: 0; } .app .actions > .panel .check-col > .muted { margin: 0; }
/* Address mode select ‖ trusted-IP field: same top offset and control height. */ /* Address mode select ‖ trusted-IP field: same top offset and control height. */
.app .actions > .panel .check-col > .muted + form > select:first-of-type, .app .actions > .panel .check-col > .muted + form > select:first-of-type,
.app .actions > .panel .check-col > .muted + form > textarea[name="allowed_ips"] { .app .actions > .panel .check-col > .muted + form > textarea[name="auth_allowed_ips"] {
margin-top: 0.45rem; margin-top: 0.45rem;
min-height: 2.55rem; min-height: 2.55rem;
box-sizing: border-box; box-sizing: border-box;
} }
.app .actions > .panel .check-col > .muted + form > textarea[name="allowed_ips"] { .app .actions > .panel .check-col > .muted + form > textarea[name="auth_allowed_ips"] {
field-sizing: fixed; field-sizing: fixed;
overflow-y: auto; overflow-y: auto;
} }
+21 -8
View File
@@ -328,16 +328,29 @@
</form> </form>
</div> </div>
<div class="check-col"> <div class="check-col">
<p class="check-col-title">Optional trusted-IP override <p class="check-col-title">Client IP allow-list
{{if .AuthIPRestrict}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">off</span>{{end}}</p>
<p class="muted">When enabled, only these addresses may authenticate
and submit mail as this application. When off, any client IP is allowed.</p>
<form method="post" action="/applications/{{.ID}}/authips">
<label class="check-row">
<input type="checkbox" name="auth_ip_restrict" value="1"
{{if .AuthIPRestrict}}checked{{end}}>
Restrict to listed IPs
</label>
<textarea name="auth_allowed_ips" rows="2" placeholder="203.0.113.10"
aria-label="Allowed client IPs">{{.AuthIPsText}}</textarea>
<button type="submit">Save IP restriction</button>
</form>
</div>
<div class="check-col">
<p class="check-col-title">Level-2 rate limit
{{if .HasLimit}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p> {{if .HasLimit}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
<p class="muted">One per line or comma-separated.</p> <p class="muted">Overrides the domain limit for this application — the
ceiling may be <em>higher or lower</em> than the domain setting
(≤ level&nbsp;1). When unset, the domain limit{{if $.DomainHasRL}}
({{$.DomainRLMaxNum}}){{end}} or level&nbsp;1 applies.</p>
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit"> <form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<textarea name="allowed_ips" rows="1" placeholder="203.0.113.10"
aria-label="Trusted client IPs">{{.IPsText}}</textarea>
<p class="muted">These IPs get a higher ceiling than the domain
(≤ level&nbsp;1) and skip the domain check; everyone else uses
the domain level-2 limit{{if $.DomainHasRL}} ({{$.DomainRLMaxNum}}){{end}}
if set, otherwise level&nbsp;1.</p>
<label for="mode-{{.ID}}">Limit mode</label> <label for="mode-{{.ID}}">Limit mode</label>
<select id="mode-{{.ID}}" name="mode" data-ratelimit-mode> <select id="mode-{{.ID}}" name="mode" data-ratelimit-mode>
<option value="manual" {{if not .IsAuto}}selected{{end}}>Manual</option> <option value="manual" {{if not .IsAuto}}selected{{end}}>Manual</option>
+7 -6
View File
@@ -130,10 +130,11 @@
digits, <code>.</code>, <code>-</code> and <code>_</code>. The password is digits, <code>.</code>, <code>-</code> and <code>_</code>. The password is
shown once.</p> shown once.</p>
<p>Address mode is which From addresses this application may use: any <p>Address mode is which From addresses this application may use: any
address of the domain, or a fixed list. A trusted-IP override gives those address of the domain, or a fixed list. Client IP allow-list, when enabled,
clients a higher ceiling than the domain (still ≤ level 1) and skips the restricts which addresses may authenticate as this application; when off,
domain check; everyone else uses the domain limit if set, otherwise level any client IP is allowed. The level-2 rate limit <em>overrides</em> the
1.</p> domain limit for that application — you can set a ceiling above or below
the domain (still ≤ level&nbsp;1). Independent of the client IP allow-list.</p>
{{template "help_more" true}} {{template "help_more" true}}
</article> </article>
@@ -141,8 +142,8 @@
<h2>Domain settings</h2> <h2>Domain settings</h2>
<p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or <p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or
you override them per domain. Level 2 is an optional ceiling for all you override them per domain. Level 2 is an optional ceiling for all
senders on this domain; it must be ≤ level 1. Application overrides live on senders on this domain; it must be ≤ level 1. Each application may override
each application.</p> that domain limit (higher or lower, still ≤ level&nbsp;1).</p>
{{template "help_more" true}} {{template "help_more" true}}
</article> </article>
+5 -4
View File
@@ -128,9 +128,10 @@ every other session; this one stays signed in.</p>
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When <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> unset, only level&nbsp;1 applies. Must be ≤ level&nbsp;1.</p>
<label>Level 2 — application (trusted IPs)</label> <label>Level 2 — application</label>
<p class="muted">Optional override: list client IPs and a ceiling <p class="muted">Overrides the domain limit for one application. The ceiling
<em>strictly above</em> the domain limit (still ≤ level&nbsp;1). Those IPs may be <em>above or below</em> the domain setting (≤ level&nbsp;1). When unset,
skip the domain check; everyone else stays under the domain (or level&nbsp;1).</p> the domain limit or level&nbsp;1 applies. Independent of the client IP
allow-list.</p>
</div> </div>
{{end}} {{end}}
+3 -3
View File
@@ -283,8 +283,8 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
if !strings.Contains(src, "Level&nbsp;1 backstop") { if !strings.Contains(src, "Level&nbsp;1 backstop") {
t.Error("domain rate limit should show a Level 1 backstop line") t.Error("domain rate limit should show a Level 1 backstop line")
} }
if !strings.Contains(src, "Trusted client IPs") { if !strings.Contains(src, "Restrict to listed IPs") {
t.Error("application override should ask for trusted client IPs") t.Error("application should offer client IP allow-list")
} }
if strings.Contains(src, `id="spf-dmarc"`) { if strings.Contains(src, `id="spf-dmarc"`) {
t.Error("SPF should sit with DKIM, not with DMARC") t.Error("SPF should sit with DKIM, not with DMARC")
@@ -306,7 +306,7 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"RATE_LIMIT_MESSAGES_PER_IP", "RATE_LIMIT_MESSAGES_PER_IP",
"Level 2 — domain", "Level 2 — domain",
"trusted IPs", "Level 2 — application",
"{{.L1Messages}} messages / {{.L1Window}} seconds", "{{.L1Messages}} messages / {{.L1Window}} seconds",
`name="send_log_retention_days"`, `name="send_log_retention_days"`,
} { } {
+1
View File
@@ -178,6 +178,7 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /domains/{id}/dmarc", h.HandleDomainDMARC) authed.HandleFunc("POST /domains/{id}/dmarc", h.HandleDomainDMARC)
authed.HandleFunc("POST /domains/{id}/export", h.HandleExportDomain) authed.HandleFunc("POST /domains/{id}/export", h.HandleExportDomain)
authed.HandleFunc("POST /applications/{aid}/mode", h.HandleUpdateAppMode) authed.HandleFunc("POST /applications/{aid}/mode", h.HandleUpdateAppMode)
authed.HandleFunc("POST /applications/{aid}/authips", h.HandleAppAuthIPs)
authed.HandleFunc("POST /applications/{aid}/password", h.HandleRegenPassword) authed.HandleFunc("POST /applications/{aid}/password", h.HandleRegenPassword)
authed.HandleFunc("POST /applications/{aid}/ratelimit", h.HandleAppRateLimit) authed.HandleFunc("POST /applications/{aid}/ratelimit", h.HandleAppRateLimit)
authed.HandleFunc("POST /applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc) authed.HandleFunc("POST /applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc)
+1 -1
View File
@@ -26,7 +26,7 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := sc.panel.setRateLimit(fmt.Sprintf("/applications/%s/ratelimit", appID), ip, 1, 3600); err != nil { if err := sc.panel.setRateLimit(fmt.Sprintf("/applications/%s/ratelimit", appID), 1, 3600); err != nil {
t.Fatalf("save application rate limit: %v", err) t.Fatalf("save application rate limit: %v", err)
} }
+2 -6
View File
@@ -195,16 +195,12 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a
} }
// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application // setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
// (/applications/{id}/ratelimit). allowedIP is required for the trusted-IP // or domain (/applications/{id}/ratelimit or /domains/{id}/ratelimit).
// override; domain ceilings are posted without IPs. func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int) error {
func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error {
vals := url.Values{ vals := url.Values{
"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) resp, body, err := c.postForm(path, vals)
if err != nil { if err != nil {
return err return err