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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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") != "":
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 1). When unset, the domain limit{{if $.DomainHasRL}}
|
||||||
|
({{$.DomainRLMaxNum}}){{end}} or level 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 1) and skip the domain check; everyone else uses
|
|
||||||
the domain level-2 limit{{if $.DomainHasRL}} ({{$.DomainRLMaxNum}}){{end}}
|
|
||||||
if set, otherwise level 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>
|
||||||
|
|||||||
@@ -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 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 1).</p>
|
||||||
{{template "help_more" true}}
|
{{template "help_more" true}}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -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 1 applies. Must be ≤ level 1.</p>
|
unset, only level 1 applies. Must be ≤ level 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 1). Those IPs
|
may be <em>above or below</em> the domain setting (≤ level 1). When unset,
|
||||||
skip the domain check; everyone else stays under the domain (or level 1).</p>
|
the domain limit or level 1 applies. Independent of the client IP
|
||||||
|
allow-list.</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -283,8 +283,8 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
|
|||||||
if !strings.Contains(src, "Level 1 backstop") {
|
if !strings.Contains(src, "Level 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"`,
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user