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]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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") != "":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 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">
|
||||
<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>
|
||||
<select id="mode-{{.ID}}" name="mode" data-ratelimit-mode>
|
||||
<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
|
||||
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 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 1).</p>
|
||||
{{template "help_more" true}}
|
||||
</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
|
||||
unset, only level 1 applies. Must be ≤ level 1.</p>
|
||||
|
||||
<label>Level 2 — application (trusted IPs)</label>
|
||||
<p class="muted">Optional override: list client IPs and a ceiling
|
||||
<em>strictly above</em> the domain limit (still ≤ level 1). Those IPs
|
||||
skip the domain check; everyone else stays under the domain (or level 1).</p>
|
||||
<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 1). When unset,
|
||||
the domain limit or level 1 applies. Independent of the client IP
|
||||
allow-list.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -283,8 +283,8 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
|
||||
if !strings.Contains(src, "Level 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"`,
|
||||
} {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user