release: 1.9.0
test / test (push) Waiting to run

Application client IP allow-list restricts which addresses may submit as a SASL login; level-2 rate limits override the domain ceiling per application (higher or lower, capped at L1). Migration 0009, authips form, milter enforcement, export/import, and operator docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 23:51:41 +03:00
parent a8ded7ecc8
commit b4a9b93cf2
26 changed files with 457 additions and 252 deletions
+73 -10
View File
@@ -4,6 +4,8 @@ import (
"database/sql"
"errors"
"fmt"
"net"
"strings"
"time"
)
@@ -27,12 +29,32 @@ const (
// hashed — so it can be shown exactly once at creation/regeneration
// (security.md). Addresses is populated only in 'list' mode.
type Application struct {
ID int64
DomainID int64
Login string
AddressMode string
CreatedAt time.Time
Addresses []string
ID int64
DomainID int64
Login string
AddressMode string
CreatedAt time.Time
Addresses []string
AuthIPRestrict bool // when true, only AuthAllowedIPs may submit as this login
AuthAllowedIPs []string // client IPs permitted when AuthIPRestrict is set
}
// AllowsAuthFromIP reports whether a client at ip may submit mail authenticated
// as this application. When AuthIPRestrict is false, every IP is allowed.
func (a Application) AllowsAuthFromIP(ip string) bool {
if !a.AuthIPRestrict {
return true
}
c := net.ParseIP(ip)
if c == nil {
return false
}
for _, allowed := range a.AuthAllowedIPs {
if p := net.ParseIP(allowed); p != nil && p.Equal(c) {
return true
}
}
return false
}
// Binding is one sender-address → login pair, as consumed by the
@@ -147,7 +169,7 @@ func normalizedList(mode string, addresses []string) []string {
// ErrApplicationNotFound.
func (s *Store) GetApplication(id int64) (Application, error) {
row := s.db.QueryRow(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE id = ?", id)
"SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips FROM applications WHERE id = ?", id)
a, err := scanApplication(row)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrApplicationNotFound
@@ -166,7 +188,7 @@ func (s *Store) GetApplication(id int64) (Application, error) {
// GetApplicationByLogin returns one application by its globally unique SASL login.
func (s *Store) GetApplicationByLogin(login string) (Application, error) {
row := s.db.QueryRow(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE login = ?", login)
"SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips FROM applications WHERE login = ?", login)
a, err := scanApplication(row)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrApplicationNotFound
@@ -182,11 +204,16 @@ func (s *Store) GetApplicationByLogin(login string) (Application, error) {
return a, nil
}
// ApplicationByLogin is the milter-facing alias for GetApplicationByLogin.
func (s *Store) ApplicationByLogin(login string) (Application, error) {
return s.GetApplicationByLogin(login)
}
// ListApplicationsByDomain returns a domain's applications ordered by login,
// each with its address list populated (product.md).
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
rows, err := s.db.Query(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE domain_id = ? ORDER BY login",
"SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips FROM applications WHERE domain_id = ? ORDER BY login",
domainID)
if err != nil {
return nil, fmt.Errorf("list applications: %w", err)
@@ -291,6 +318,31 @@ func (s *Store) ListBindings() ([]Binding, error) {
return out, rows.Err()
}
// UpdateApplicationAuthIPs sets whether client IP restriction is active for an
// application and, when active, the permitted client addresses. The caller has
// already validated the IPs (security.md). Returns ErrApplicationNotFound when
// the id does not exist.
func (s *Store) UpdateApplicationAuthIPs(id int64, restrict bool, ips []string) error {
if !restrict {
ips = nil
}
res, err := s.db.Exec(
"UPDATE applications SET auth_ip_restrict = ?, auth_allowed_ips = ? WHERE id = ?",
boolToInt(restrict), strings.Join(ips, ","), id,
)
if err != nil {
return fmt.Errorf("update application auth IPs: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update application auth IPs rows: %w", err)
}
if n == 0 {
return ErrApplicationNotFound
}
return nil
}
// DeleteApplication removes an application and its addresses (via cascade),
// returning the deleted application so the caller can drop its sasldb2 entry
// (product.md). Returns ErrApplicationNotFound if no such row existed.
@@ -336,10 +388,21 @@ func scanApplication(r scanRow) (Application, error) {
var (
a Application
createdAt string
restrict int
ips sql.NullString
)
if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt); err != nil {
if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt, &restrict, &ips); err != nil {
return Application{}, err
}
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
a.AuthIPRestrict = restrict != 0
a.AuthAllowedIPs = splitIPs(ips.String)
return a, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+40
View File
@@ -187,3 +187,43 @@ func TestListLoginsByDomain(t *testing.T) {
t.Fatalf("logins = %v, want [a b]", logins)
}
}
func TestApplicationAuthIPs(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
a, err := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
if err != nil {
t.Fatal(err)
}
if a.AuthIPRestrict || len(a.AuthAllowedIPs) != 0 {
t.Fatalf("new app should have no IP restriction: %+v", a)
}
if err := st.UpdateApplicationAuthIPs(a.ID, true, []string{"203.0.113.1", "2001:db8::1"}); err != nil {
t.Fatalf("UpdateApplicationAuthIPs: %v", err)
}
got, err := st.GetApplication(a.ID)
if err != nil {
t.Fatal(err)
}
if !got.AuthIPRestrict || len(got.AuthAllowedIPs) != 2 {
t.Fatalf("after update: %+v", got)
}
if !got.AllowsAuthFromIP("203.0.113.1") || !got.AllowsAuthFromIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
t.Fatal("listed IPs should match")
}
if got.AllowsAuthFromIP("198.51.100.7") {
t.Fatal("unlisted IP must not match")
}
if err := st.UpdateApplicationAuthIPs(a.ID, false, nil); err != nil {
t.Fatal(err)
}
got, _ = st.GetApplication(a.ID)
if got.AuthIPRestrict || len(got.AuthAllowedIPs) != 0 {
t.Fatalf("cleared restriction: %+v", got)
}
if !got.AllowsAuthFromIP("198.51.100.7") {
t.Fatal("restriction off should allow any IP")
}
}
@@ -0,0 +1,22 @@
-- Client IP restriction for application SASL authorization (independent of
-- level-2 rate limits). When enabled, only listed IPs may submit mail as the
-- application; when disabled, any client IP is allowed.
ALTER TABLE applications ADD COLUMN auth_ip_restrict INTEGER NOT NULL DEFAULT 0;
ALTER TABLE applications ADD COLUMN auth_allowed_ips TEXT;
-- Migrate trusted IPs from application rate_limits into auth_allowed_ips.
UPDATE applications
SET auth_ip_restrict = 1,
auth_allowed_ips = (
SELECT allowed_ips FROM rate_limits
WHERE scope = 'application' AND ref_id = applications.id
)
WHERE EXISTS (
SELECT 1 FROM rate_limits
WHERE scope = 'application' AND ref_id = applications.id
AND allowed_ips IS NOT NULL AND TRIM(allowed_ips) != ''
);
UPDATE rate_limits SET allowed_ips = NULL
WHERE scope = 'application' AND allowed_ips IS NOT NULL;
-25
View File
@@ -92,8 +92,6 @@ func (s *Store) recalcAutoRateLimit(rl RateLimit, retentionDays, l1Max, l1Window
}
var stats SendStats
var domainMax int
var domainActive bool
switch rl.Scope {
case RateLimitScopeDomain:
@@ -114,22 +112,11 @@ func (s *Store) recalcAutoRateLimit(rl RateLimit, retentionDays, l1Max, l1Window
if err != nil {
return err
}
domainRL, ok, err := s.GetRateLimit(RateLimitScopeDomain, a.DomainID)
if err != nil {
return err
}
domainActive = ok && domainRL.Active()
if domainActive {
domainMax = domainRL.MaxMessages
}
default:
return fmt.Errorf("unknown scope %q", rl.Scope)
}
maxMsgs := computeAutoMaxMessages(stats, mult, l1Max)
if rl.Scope == RateLimitScopeApp {
maxMsgs = adjustAppAutoMax(maxMsgs, domainMax, domainActive, l1Max)
}
rl.MaxMessages = maxMsgs
rl.WindowSeconds = l1Window
@@ -152,15 +139,3 @@ func computeAutoMaxMessages(stats SendStats, multiplier float64, l1Max int) int
return max
}
func adjustAppAutoMax(appMax, domainMax int, domainActive bool, l1Max int) int {
if appMax <= 0 {
return 0
}
if domainActive && appMax <= domainMax {
appMax = domainMax + 1
if appMax > l1Max {
return 0
}
}
return appMax
}
+5 -32
View File
@@ -4,7 +4,6 @@ import (
"database/sql"
"errors"
"fmt"
"net"
"strings"
"time"
)
@@ -36,14 +35,13 @@ const (
// architecture.md § Mail path) is the IP backstop that always applies even
// when this is absent or the milter is down.
//
// Domain limits apply to every client IP once max and window are set. Application
// limits additionally require AllowedIPs: those trusted addresses get the app
// ceiling (above the domain) and skip the domain check; other IPs stay under
// the domain limit or level 1 alone (guide § Rate limiting).
// Domain and application limits apply once max and window are set. Client IP
// restriction for an application is stored on the application row, not here
// (guide § Rate limiting).
type RateLimit struct {
Scope string
RefID int64
AllowedIPs []string // trusted client IPs for an application override
AllowedIPs []string // legacy column; unused for new rows
MaxMessages int
WindowSeconds int
Mode string // manual | auto
@@ -52,38 +50,13 @@ type RateLimit struct {
}
// Active reports whether the limit is fully configured and should be enforced.
// Domain: max and window only. Application: also needs at least one trusted IP
// (the privilege that raises the ceiling above the domain).
// IsAuto reports whether the limit derives max_messages from send statistics.
func (r RateLimit) IsAuto() bool {
return r.Mode == RateLimitModeAuto
}
func (r RateLimit) Active() bool {
if r.MaxMessages <= 0 || r.WindowSeconds <= 0 {
return false
}
if r.Scope == RateLimitScopeApp {
return len(r.AllowedIPs) > 0
}
// Domain (and any unset/legacy scope treated as domain-style): no IP list.
return true
}
// AllowsIP reports whether ip is one of the application's trusted client IPs.
// Used only for application overrides; domain limits do not consult this list.
// Equivalent textual forms of the same address match.
func (r RateLimit) AllowsIP(ip string) bool {
c := net.ParseIP(ip)
if c == nil {
return false
}
for _, a := range r.AllowedIPs {
if p := net.ParseIP(a); p != nil && p.Equal(c) {
return true
}
}
return false
return r.MaxMessages > 0 && r.WindowSeconds > 0
}
// GetRateLimit loads the level-2 limit configured for a domain or application by
+17 -30
View File
@@ -150,39 +150,26 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
}
}
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
func TestRateLimitActive(t *testing.T) {
inactive := []RateLimit{
{},
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs
{Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
}
for i, rl := range inactive {
if rl.Active() {
t.Fatalf("case %d: %+v should be inactive", i, rl)
}
}
domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60}
if !domainActive.Active() {
t.Fatalf("domain without IPs should be active: %+v", domainActive)
active := []RateLimit{
{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60},
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60},
}
appActive := RateLimit{
Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1", "2001:db8::1"},
MaxMessages: 5, WindowSeconds: 60,
}
if !appActive.Active() {
t.Fatalf("should be active: %+v", appActive)
}
if !appActive.AllowsIP("203.0.113.1") || !appActive.AllowsIP("2001:db8::1") {
t.Fatalf("registered IPs should match")
}
// Equivalent textual form of the IPv6 address must still match.
if !appActive.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
t.Fatalf("expanded IPv6 form should match")
}
if appActive.AllowsIP("198.51.100.7") || appActive.AllowsIP("not-an-ip") || appActive.AllowsIP("") {
t.Fatalf("unregistered/invalid IPs must not match")
for i, rl := range active {
if !rl.Active() {
t.Fatalf("case %d: %+v should be active", i, rl)
}
}
}
@@ -219,14 +206,14 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("window = %d, want 3600", rl.WindowSeconds)
}
// Domain limit at ceiling; app auto must be strictly above or inactive at L1.
// Domain limit at ceiling; app auto is capped at L1 independently.
_ = st.SetRateLimit(RateLimit{
Scope: RateLimitScopeDomain, RefID: d.ID, Mode: RateLimitModeManual,
MaxMessages: 100, WindowSeconds: 3600,
})
if err := st.SetRateLimit(RateLimit{
Scope: RateLimitScopeApp, RefID: a.ID, Mode: RateLimitModeAuto,
AllowedIPs: []string{"203.0.113.1"}, AutoMultiplier: 2.0,
AutoMultiplier: 2.0,
}); err != nil {
t.Fatalf("SetRateLimit app: %v", err)
}
@@ -234,8 +221,8 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("RecalcAutoRateLimit app: %v", err)
}
appRL, ok, _ := st.GetRateLimit(RateLimitScopeApp, a.ID)
if ok && appRL.Active() {
t.Fatalf("app auto at L1 cap with domain at L1 should be inactive: %+v", appRL)
if !ok || !appRL.Active() || appRL.MaxMessages > 100 {
t.Fatalf("app auto at L1 cap should still be active: %+v", appRL)
}
_ = st.SetRateLimit(RateLimit{
@@ -246,8 +233,8 @@ func TestAutoRateLimitRecalc(t *testing.T) {
t.Fatalf("RecalcAutoRateLimit app: %v", err)
}
appRL, ok, _ = st.GetRateLimit(RateLimitScopeApp, a.ID)
if !ok || !appRL.Active() || appRL.MaxMessages <= 40 {
t.Fatalf("app auto should be above domain 40: %+v", appRL)
if !ok || !appRL.Active() {
t.Fatalf("app auto should remain active with domain at 40: %+v", appRL)
}
// Milter reads the stored ceiling via RateLimit(name/login).