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]
## [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
- 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
methods (panel, `selfpost-backup`, stopped `tar` of the project directory,
stopped `tar` of `./data` only), warns against `tar` on a live container, and
+1 -1
View File
@@ -22,7 +22,7 @@
services:
selfpost:
image: ghcr.io/mixeme/selfpost:1.8.0
image: ghcr.io/mixeme/selfpost:1.9.0
restart: unless-stopped
environment:
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
administrator only**). Domain administrators see only domains assigned to
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
applications. Per-domain rate limits (level 2) and trusted-IP application
overrides are configured here — see [Rate limiting —
applications. Per-domain rate limits (level 2), per-application limits, and
optional client IP allow-lists are configured here — see [Rate limiting —
level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
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
@@ -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
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.
**Client IP allow-list (application)** — optional restriction on an
application: when enabled, list one or more client IPs that may authenticate
and submit mail as that application. When disabled, any client IP is allowed.
This is independent of rate limits.
**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** (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
use `min(30, retention)` days. With no traffic in the window, auto stays
inactive until messages are sent. Auto limits are recalculated every six
hours and on demand via **Recalculate now**. Application auto ceilings stay
strictly above an active domain limit when possible.
hours and on demand via **Recalculate now**.
**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
@@ -889,7 +891,7 @@ default until it is changed there.
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
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)
doesn't need to change. Unlike a full restore (see [Full backup and
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)
}
// 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.
// SaveRateLimit stores the application-level rate limit (guide § Rate
// limiting). The caller has validated the numbers (security.md); the milter
// reads the row live, so no reload is needed.
func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error {
rl.Scope = store.RateLimitScopeApp
rl.RefID = appID
rl.AllowedIPs = nil
return s.store.SetRateLimit(rl)
}
@@ -214,6 +215,12 @@ func (s *Service) ClearRateLimit(appID int64) error {
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
// 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
+34 -16
View File
@@ -40,11 +40,13 @@ type RateLimitExport struct {
// AppExport is one application within a DomainExport.
type AppExport struct {
Login string `json:"login"`
AddressMode string `json:"address_mode"`
Addresses []string `json:"addresses,omitempty"` // list mode only
Password string `json:"password"`
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
Login string `json:"login"`
AddressMode string `json:"address_mode"`
Addresses []string `json:"addresses,omitempty"` // list mode only
Password string `json:"password"`
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
@@ -85,10 +87,12 @@ func (s *Service) Export(id int64) (DomainExport, error) {
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
}
appExp := AppExport{
Login: a.Login,
AddressMode: a.AddressMode,
Addresses: a.Addresses,
Password: password,
Login: a.Login,
AddressMode: a.AddressMode,
Addresses: a.Addresses,
Password: password,
AuthIPRestrict: a.AuthIPRestrict,
AuthAllowedIPs: a.AuthAllowedIPs,
}
if rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeApp, a.ID); err == nil && ok {
appExp.RateLimit = exportRateLimit(rl)
@@ -153,15 +157,31 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
s.importRollback(d.ID)
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)
if err != nil {
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 {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import rate limit for %q: %w", a.Login, err)
if needAuthIPs {
if err := s.store.UpdateApplicationAuthIPs(app.ID, restrict, ips); err != nil {
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,
WindowSeconds: rl.WindowSeconds,
AutoMultiplier: rl.AutoMultiplier,
AllowedIPs: rl.AllowedIPs,
}
return exp
}
@@ -208,7 +227,6 @@ func (s *Service) importRateLimit(scope string, refID int64, exp RateLimitExport
MaxMessages: exp.MaxMessages,
WindowSeconds: exp.WindowSeconds,
AutoMultiplier: exp.AutoMultiplier,
AllowedIPs: exp.AllowedIPs,
}
if mode == store.RateLimitModeManual && rl.MaxMessages <= 0 && rl.WindowSeconds <= 0 {
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
RateLimit(scope, ref string) (store.RateLimit, bool, 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.
@@ -84,6 +85,10 @@ func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, er
s.login = macro(m, "auth_authen")
s.rcpts = nil
s.subject = ""
if !s.authIPAllowed() {
s.recordRejected()
return milter.RespTempFail, nil
}
if s.overLimit() {
s.recordRejected()
return milter.RespTempFail, nil
+52 -31
View File
@@ -25,10 +25,12 @@ type fakeRecorder struct {
fail bool
// 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
// countErr force the store errors that must fail open.
// gives the recent-message count returned for a "scope|ref". apps supplies
// application rows for client-IP authorization tests. lookupErr and countErr
// force the store errors that must fail open.
limits map[string]store.RateLimit
counts map[string]int64
apps map[string]store.Application
lookupErr error
countErr error
@@ -75,6 +77,17 @@ func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, err
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 {
return &milter.Modifier{Macros: kv}
}
@@ -211,8 +224,8 @@ 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}
func appLimit() store.RateLimit {
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
}
// 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) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
store.RateLimitScopeApp + "|app1": appLimit(),
},
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{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
store.RateLimitScopeApp + "|app1": {
AllowedIPs: []string{limitIP}, MaxMessages: 10, WindowSeconds: 3600,
},
store.RateLimitScopeApp + "|app1": appLimit(),
},
counts: map[string]int64{
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 {
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{
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,
apps: map[string]store.Application{
"app1": {
Login: "app1",
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 {
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{
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,
apps: map[string]store.Application{
"app1": {
Login: "app1",
AuthIPRestrict: true,
AuthAllowedIPs: []string{limitIP},
},
},
}
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)
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
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{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(),
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
store.RateLimitScopeApp + "|app1": appLimit(),
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 0,
+5 -4
View File
@@ -10,9 +10,10 @@ import (
// overLimit reports whether the message currently being received should be
// 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.
// When an application has an active limit, it overrides the domain limit for
// that login (the ceiling may be higher or lower than the domain). Otherwise
// 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
// 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)
if err != nil {
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)
}
}
+73 -10
View File
@@ -4,6 +4,8 @@ import (
"database/sql"
"errors"
"fmt"
"net"
"strings"
"time"
)
@@ -27,12 +29,32 @@ const (
// hashed — so it can be shown exactly once at creation/regeneration
// (security.md). Addresses is populated only in 'list' mode.
type Application struct {
ID int64
DomainID int64
Login string
AddressMode string
CreatedAt time.Time
Addresses []string
ID int64
DomainID int64
Login string
AddressMode string
CreatedAt time.Time
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
@@ -147,7 +169,7 @@ func normalizedList(mode string, addresses []string) []string {
// ErrApplicationNotFound.
func (s *Store) GetApplication(id int64) (Application, error) {
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)
if errors.Is(err, sql.ErrNoRows) {
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.
func (s *Store) GetApplicationByLogin(login string) (Application, error) {
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)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrApplicationNotFound
@@ -182,11 +204,16 @@ func (s *Store) GetApplicationByLogin(login string) (Application, error) {
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,
// each with its address list populated (product.md).
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
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)
if err != nil {
return nil, fmt.Errorf("list applications: %w", err)
@@ -291,6 +318,31 @@ func (s *Store) ListBindings() ([]Binding, error) {
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),
// returning the deleted application so the caller can drop its sasldb2 entry
// (product.md). Returns ErrApplicationNotFound if no such row existed.
@@ -336,10 +388,21 @@ func scanApplication(r scanRow) (Application, error) {
var (
a Application
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
}
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
a.AuthIPRestrict = restrict != 0
a.AuthAllowedIPs = splitIPs(ips.String)
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)
}
}
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 domainMax int
var domainActive bool
switch rl.Scope {
case RateLimitScopeDomain:
@@ -114,22 +112,11 @@ func (s *Store) recalcAutoRateLimit(rl RateLimit, retentionDays, l1Max, l1Window
if err != nil {
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:
return fmt.Errorf("unknown scope %q", rl.Scope)
}
maxMsgs := computeAutoMaxMessages(stats, mult, l1Max)
if rl.Scope == RateLimitScopeApp {
maxMsgs = adjustAppAutoMax(maxMsgs, domainMax, domainActive, l1Max)
}
rl.MaxMessages = maxMsgs
rl.WindowSeconds = l1Window
@@ -152,15 +139,3 @@ func computeAutoMaxMessages(stats SendStats, multiplier float64, l1Max int) int
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"
"errors"
"fmt"
"net"
"strings"
"time"
)
@@ -36,14 +35,13 @@ const (
// architecture.md § Mail path) is the IP backstop that always applies even
// when this is absent or the milter is down.
//
// 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).
// Domain and application limits apply once max and window are set. Client IP
// restriction for an application is stored on the application row, not here
// (guide § Rate limiting).
type RateLimit struct {
Scope string
RefID int64
AllowedIPs []string // trusted client IPs for an application override
AllowedIPs []string // legacy column; unused for new rows
MaxMessages int
WindowSeconds int
Mode string // manual | auto
@@ -52,38 +50,13 @@ type RateLimit struct {
}
// 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.
func (r RateLimit) IsAuto() bool {
return r.Mode == RateLimitModeAuto
}
func (r RateLimit) Active() bool {
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 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
return r.MaxMessages > 0 && r.WindowSeconds > 0
}
// 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{
{},
{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
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
}
for i, rl := range inactive {
if rl.Active() {
t.Fatalf("case %d: %+v should be inactive", i, rl)
}
}
domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60}
if !domainActive.Active() {
t.Fatalf("domain without IPs should be active: %+v", domainActive)
active := []RateLimit{
{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60},
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60},
}
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 !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")
for i, rl := range active {
if !rl.Active() {
t.Fatalf("case %d: %+v should be active", i, rl)
}
}
}
@@ -219,14 +206,14 @@ func TestAutoRateLimitRecalc(t *testing.T) {
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{
Scope: RateLimitScopeDomain, RefID: d.ID, Mode: RateLimitModeManual,
MaxMessages: 100, WindowSeconds: 3600,
})
if err := st.SetRateLimit(RateLimit{
Scope: RateLimitScopeApp, RefID: a.ID, Mode: RateLimitModeAuto,
AllowedIPs: []string{"203.0.113.1"}, AutoMultiplier: 2.0,
AutoMultiplier: 2.0,
}); err != nil {
t.Fatalf("SetRateLimit app: %v", err)
}
@@ -234,8 +221,8 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("RecalcAutoRateLimit app: %v", err)
}
appRL, ok, _ := st.GetRateLimit(RateLimitScopeApp, a.ID)
if ok && appRL.Active() {
t.Fatalf("app auto at L1 cap with domain at L1 should be inactive: %+v", appRL)
if !ok || !appRL.Active() || appRL.MaxMessages > 100 {
t.Fatalf("app auto at L1 cap should still be active: %+v", appRL)
}
_ = st.SetRateLimit(RateLimit{
@@ -246,8 +233,8 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("RecalcAutoRateLimit app: %v", err)
}
appRL, ok, _ = st.GetRateLimit(RateLimitScopeApp, a.ID)
if !ok || !appRL.Active() || appRL.MaxMessages <= 40 {
t.Fatalf("app auto should be above domain 40: %+v", appRL)
if !ok || !appRL.Active() {
t.Fatalf("app auto should remain active with domain at 40: %+v", appRL)
}
// Milter reads the stored ceiling via RateLimit(name/login).
+4 -2
View File
@@ -46,7 +46,7 @@ type detailView struct {
type appRateLimitView struct {
store.Application
HasLimit bool
IPsText string
AuthIPsText string
MaxText string
WindowVal string
Mode string
@@ -116,7 +116,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
appViews = append(appViews, appRateLimitView{
Application: a,
HasLimit: ok && rl.Active(),
IPsText: strings.Join(rl.AllowedIPs, "\n"),
AuthIPsText: strings.Join(a.AuthAllowedIPs, "\n"),
MaxText: intOrBlank(rl.MaxMessages),
WindowVal: windowOrDefault(rl.WindowSeconds),
Mode: mode,
@@ -332,6 +332,8 @@ func detailFlash(r *http.Request) string {
return "Application address mode updated."
case r.URL.Query().Get("ratelimit") != "":
return "Rate limit updated."
case r.URL.Query().Get("authips") != "":
return "Client IP restriction updated."
case r.URL.Query().Get("recalculated") != "":
return "Auto rate limit recalculated."
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
}
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 {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
}
@@ -110,20 +110,12 @@ func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive b
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 {
mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier"))
if err != nil {
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"))
@@ -137,14 +129,29 @@ func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive b
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")
}
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) {
@@ -206,14 +213,7 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
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)
in, err := parseAppRateLimitForm(r, h.l1Messages())
if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
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)
}
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) {
d, ok := h.lookupDomain(w, r)
if !ok {
@@ -294,7 +320,6 @@ func (h *Handlers) applyAppRateLimit(in rateLimitInput, appID int64) error {
rl := store.RateLimit{
Scope: store.RateLimitScopeApp,
RefID: appID,
AllowedIPs: in.ips,
Mode: in.mode,
MaxMessages: in.maxMessages,
WindowSeconds: in.windowSeconds,
@@ -50,52 +50,52 @@ func TestParseAppRateLimitForm(t *testing.T) {
in, err := parseAppRateLimitForm(form(url.Values{
"mode": {"manual"},
"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)
}), 100)
if err != nil || in.maxMessages != 80 {
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{
"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"},
"window_seconds": {"3600"},
}), 100, 0, false)
}), 100)
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{
"mode": {"manual"},
"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)
func TestParseAppAuthIPsForm(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
}
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; }
/* 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 > textarea[name="allowed_ips"] {
.app .actions > .panel .check-col > .muted + form > textarea[name="auth_allowed_ips"] {
margin-top: 0.45rem;
min-height: 2.55rem;
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;
overflow-y: auto;
}
+21 -8
View File
@@ -328,16 +328,29 @@
</form>
</div>
<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>
<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">
<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>
<select id="mode-{{.ID}}" name="mode" data-ratelimit-mode>
<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
shown once.</p>
<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
clients a higher ceiling than the domain (still ≤ level 1) and skips the
domain check; everyone else uses the domain limit if set, otherwise level
1.</p>
address of the domain, or a fixed list. Client IP allow-list, when enabled,
restricts which addresses may authenticate as this application; when off,
any client IP is allowed. The level-2 rate limit <em>overrides</em> the
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}}
</article>
@@ -141,8 +142,8 @@
<h2>Domain settings</h2>
<p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or
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
each application.</p>
senders on this domain; it must be ≤ level 1. Each application may override
that domain limit (higher or lower, still ≤ level&nbsp;1).</p>
{{template "help_more" true}}
</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
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>
<label>Level 2 — application</label>
<p class="muted">Overrides the domain limit for one application. The ceiling
may be <em>above or below</em> the domain setting (≤ level&nbsp;1). When unset,
the domain limit or level&nbsp;1 applies. Independent of the client IP
allow-list.</p>
</div>
{{end}}
+3 -3
View File
@@ -283,8 +283,8 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
if !strings.Contains(src, "Level&nbsp;1 backstop") {
t.Error("domain rate limit should show a Level 1 backstop line")
}
if !strings.Contains(src, "Trusted client IPs") {
t.Error("application override should ask for trusted client IPs")
if !strings.Contains(src, "Restrict to listed IPs") {
t.Error("application should offer client IP allow-list")
}
if strings.Contains(src, `id="spf-dmarc"`) {
t.Error("SPF should sit with DKIM, not with DMARC")
@@ -306,7 +306,7 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
for _, want := range []string{
"RATE_LIMIT_MESSAGES_PER_IP",
"Level 2 — domain",
"trusted IPs",
"Level 2 — application",
"{{.L1Messages}} messages / {{.L1Window}} seconds",
`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}/export", h.HandleExportDomain)
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}/ratelimit", h.HandleAppRateLimit)
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 {
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)
}
+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
// (/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 {
// or domain (/applications/{id}/ratelimit or /domains/{id}/ratelimit).
func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int) error {
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