release: 1.9.0: Per-application client IP allow-list and rate-limit overrides

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,
  - SQLite migration `0009_application_auth_ips.sql` (moves legacy trusted IPs
Changed:
  - Application level-2 rate limit **overrides** the domain limit for that login
  - Domain export/import carries `auth_ip_restrict` / `auth_allowed_ips`; legacy
  - Panel help, Settings, and [guide.md](docs/guide.md) describe override semantics
  - [guide.md](docs/guide.md): full-backup section documents all four capture

Full detail: CHANGELOG.md (1.9.0).
This commit is contained in:
mix
2026-09-22 15:24:53 +03:00
parent 9d8b19d14a
commit 7b8685d038
26 changed files with 561 additions and 278 deletions
+17 -30
View File
@@ -150,39 +150,26 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
}
}
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
func TestRateLimitActive(t *testing.T) {
inactive := []RateLimit{
{},
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs
{Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
}
for i, rl := range inactive {
if rl.Active() {
t.Fatalf("case %d: %+v should be inactive", i, rl)
}
}
domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60}
if !domainActive.Active() {
t.Fatalf("domain without IPs should be active: %+v", domainActive)
active := []RateLimit{
{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60},
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60},
}
appActive := RateLimit{
Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1", "2001:db8::1"},
MaxMessages: 5, WindowSeconds: 60,
}
if !appActive.Active() {
t.Fatalf("should be active: %+v", appActive)
}
if !appActive.AllowsIP("203.0.113.1") || !appActive.AllowsIP("2001:db8::1") {
t.Fatalf("registered IPs should match")
}
// Equivalent textual form of the IPv6 address must still match.
if !appActive.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
t.Fatalf("expanded IPv6 form should match")
}
if appActive.AllowsIP("198.51.100.7") || appActive.AllowsIP("not-an-ip") || appActive.AllowsIP("") {
t.Fatalf("unregistered/invalid IPs must not match")
for i, rl := range active {
if !rl.Active() {
t.Fatalf("case %d: %+v should be active", i, rl)
}
}
}
@@ -219,14 +206,14 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("window = %d, want 3600", rl.WindowSeconds)
}
// Domain limit at ceiling; app auto must be strictly above or inactive at L1.
// Domain limit at ceiling; app auto is capped at L1 independently.
_ = st.SetRateLimit(RateLimit{
Scope: RateLimitScopeDomain, RefID: d.ID, Mode: RateLimitModeManual,
MaxMessages: 100, WindowSeconds: 3600,
})
if err := st.SetRateLimit(RateLimit{
Scope: RateLimitScopeApp, RefID: a.ID, Mode: RateLimitModeAuto,
AllowedIPs: []string{"203.0.113.1"}, AutoMultiplier: 2.0,
AutoMultiplier: 2.0,
}); err != nil {
t.Fatalf("SetRateLimit app: %v", err)
}
@@ -234,8 +221,8 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("RecalcAutoRateLimit app: %v", err)
}
appRL, ok, _ := st.GetRateLimit(RateLimitScopeApp, a.ID)
if ok && appRL.Active() {
t.Fatalf("app auto at L1 cap with domain at L1 should be inactive: %+v", appRL)
if !ok || !appRL.Active() || appRL.MaxMessages > 100 {
t.Fatalf("app auto at L1 cap should still be active: %+v", appRL)
}
_ = st.SetRateLimit(RateLimit{
@@ -246,8 +233,8 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("RecalcAutoRateLimit app: %v", err)
}
appRL, ok, _ = st.GetRateLimit(RateLimitScopeApp, a.ID)
if !ok || !appRL.Active() || appRL.MaxMessages <= 40 {
t.Fatalf("app auto should be above domain 40: %+v", appRL)
if !ok || !appRL.Active() {
t.Fatalf("app auto should remain active with domain at 40: %+v", appRL)
}
// Milter reads the stored ceiling via RateLimit(name/login).