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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user