From b4a9b93cf2e62a29bd73e40dd07c9e1ee9fbe714 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Tue, 18 Aug 2026 23:51:41 +0300 Subject: [PATCH] release: 1.9.0 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 --- CHANGELOG.md | 21 +++++ deploy/docker-compose.yml | 2 +- docs/guide.md | 24 +++--- internal/app/service.go | 13 ++- internal/domain/transfer.go | 50 +++++++---- internal/milter/authip.go | 31 +++++++ internal/milter/milter.go | 5 ++ internal/milter/milter_test.go | 83 ++++++++++++------- internal/milter/ratelimit.go | 9 +- internal/store/applications.go | 83 ++++++++++++++++--- internal/store/applications_test.go | 40 +++++++++ .../migrations/0009_application_auth_ips.sql | 22 +++++ internal/store/ratelimit_auto.go | 25 ------ internal/store/ratelimits.go | 37 ++------- internal/store/ratelimits_test.go | 47 ++++------- internal/web/handlers/handlers_apps.go | 6 +- internal/web/handlers/handlers_ratelimit.go | 71 +++++++++++----- .../web/handlers/handlers_ratelimit_test.go | 68 +++++++-------- internal/web/view/static/panel.css | 4 +- .../web/view/templates/domain_detail.html | 29 +++++-- internal/web/view/templates/help_drawer.html | 13 +-- internal/web/view/templates/settings.html | 9 +- internal/web/view/templates_test.go | 6 +- internal/web/web.go | 1 + test/e2e/negative_test.go | 2 +- test/e2e/panel_client.go | 8 +- 26 files changed, 457 insertions(+), 252 deletions(-) create mode 100644 internal/milter/authip.go create mode 100644 internal/store/migrations/0009_application_auth_ips.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index f03aaad..67cbfa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 6c9ecb1..b565a70 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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}" diff --git a/docs/guide.md b/docs/guide.md index 17f18c7..0e90656 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -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 diff --git a/internal/app/service.go b/internal/app/service.go index c68f383..8ebb5ae 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -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 diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go index 7e3a2f2..36dde8f 100644 --- a/internal/domain/transfer.go +++ b/internal/domain/transfer.go @@ -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 diff --git a/internal/milter/authip.go b/internal/milter/authip.go new file mode 100644 index 0000000..7b3c483 --- /dev/null +++ b/internal/milter/authip.go @@ -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 +} diff --git a/internal/milter/milter.go b/internal/milter/milter.go index cb6808e..aed7e31 100644 --- a/internal/milter/milter.go +++ b/internal/milter/milter.go @@ -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 diff --git a/internal/milter/milter_test.go b/internal/milter/milter_test.go index 0d2e167..568e5b4 100644 --- a/internal/milter/milter_test.go +++ b/internal/milter/milter_test.go @@ -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, diff --git a/internal/milter/ratelimit.go b/internal/milter/ratelimit.go index 291c79b..d713936 100644 --- a/internal/milter/ratelimit.go +++ b/internal/milter/ratelimit.go @@ -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) } } diff --git a/internal/store/applications.go b/internal/store/applications.go index 2d93a46..6a94b5c 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -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 +} diff --git a/internal/store/applications_test.go b/internal/store/applications_test.go index b853de8..5091e0c 100644 --- a/internal/store/applications_test.go +++ b/internal/store/applications_test.go @@ -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") + } +} diff --git a/internal/store/migrations/0009_application_auth_ips.sql b/internal/store/migrations/0009_application_auth_ips.sql new file mode 100644 index 0000000..c4bf48e --- /dev/null +++ b/internal/store/migrations/0009_application_auth_ips.sql @@ -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; diff --git a/internal/store/ratelimit_auto.go b/internal/store/ratelimit_auto.go index 6742bc7..92e8f67 100644 --- a/internal/store/ratelimit_auto.go +++ b/internal/store/ratelimit_auto.go @@ -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 -} diff --git a/internal/store/ratelimits.go b/internal/store/ratelimits.go index ee13fef..f69bf28 100644 --- a/internal/store/ratelimits.go +++ b/internal/store/ratelimits.go @@ -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 diff --git a/internal/store/ratelimits_test.go b/internal/store/ratelimits_test.go index 020e222..f562c7c 100644 --- a/internal/store/ratelimits_test.go +++ b/internal/store/ratelimits_test.go @@ -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). diff --git a/internal/web/handlers/handlers_apps.go b/internal/web/handlers/handlers_apps.go index 9606066..6c4cd03 100644 --- a/internal/web/handlers/handlers_apps.go +++ b/internal/web/handlers/handlers_apps.go @@ -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") != "": diff --git a/internal/web/handlers/handlers_ratelimit.go b/internal/web/handlers/handlers_ratelimit.go index a278bb3..2b2fb71 100644 --- a/internal/web/handlers/handlers_ratelimit.go +++ b/internal/web/handlers/handlers_ratelimit.go @@ -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, diff --git a/internal/web/handlers/handlers_ratelimit_test.go b/internal/web/handlers/handlers_ratelimit_test.go index 4d5453f..d094e75 100644 --- a/internal/web/handlers/handlers_ratelimit_test.go +++ b/internal/web/handlers/handlers_ratelimit_test.go @@ -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) } } diff --git a/internal/web/view/static/panel.css b/internal/web/view/static/panel.css index cfae068..19145b6 100644 --- a/internal/web/view/static/panel.css +++ b/internal/web/view/static/panel.css @@ -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; } diff --git a/internal/web/view/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html index e422802..d434e34 100644 --- a/internal/web/view/templates/domain_detail.html +++ b/internal/web/view/templates/domain_detail.html @@ -328,16 +328,29 @@
-

Optional trusted-IP override +

Client IP allow-list + {{if .AuthIPRestrict}}active{{else}}off{{end}}

+

When enabled, only these addresses may authenticate + and submit mail as this application. When off, any client IP is allowed.

+
+ + + +
+
+
+

Level-2 rate limit {{if .HasLimit}}active{{else}}inactive{{end}}

-

One per line or comma-separated.

+

Overrides the domain limit for this application — the + ceiling may be higher or lower than the domain setting + (≤ level 1). When unset, the domain limit{{if $.DomainHasRL}} + ({{$.DomainRLMaxNum}}){{end}} or level 1 applies.

- -

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.