From c3cc8b43745317db6fb3e114b53e350f0fbdf7a4 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 2 Jul 2026 16:24:18 +0200 Subject: [PATCH] fix(job): gate ip-limit scan on clients.limit_ip instead of parsing all settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasLimitIp ran settings LIKE '%limitIp%' and JSON-parsed every matching inbound's settings blob — and since clients marshal limitIp without omitempty, every inbound matched, so each 10s scan loaded and parsed every settings blob in the database (~75MB of JSON at 500k clients) just to decide whether any limit exists. It now probes the normalized clients table (limit_ip > 0, Limit(1) count like depletedCond does), which SyncInbound and the legacy seeder keep in sync with the settings JSON. Semantics note: a limitIp that exists only in settings JSON with no clients row no longer enables enforcement — the enforcement path itself already resolves clients through the same normalized tables. --- internal/web/job/check_client_ip_job.go | 32 ++++--------------- .../check_client_ip_job_integration_test.go | 25 +++++++++++++++ 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/internal/web/job/check_client_ip_job.go b/internal/web/job/check_client_ip_job.go index a28bae94c..5faa76e35 100644 --- a/internal/web/job/check_client_ip_job.go +++ b/internal/web/job/check_client_ip_job.go @@ -113,33 +113,15 @@ func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64, return observed, true } +// hasLimitIp reports whether any client carries an IP limit. It probes the +// normalized clients table (limit_ip is synced there by SyncInbound and the +// legacy seeder), replacing the old `settings LIKE '%limitIp%'` scan that +// loaded and JSON-parsed every inbound's settings blob on each 10s run. func (j *CheckClientIpJob) hasLimitIp() bool { db := database.GetDB() - var inbounds []*model.Inbound - - err := db.Model(model.Inbound{}).Where("settings LIKE ?", "%limitIp%").Find(&inbounds).Error - if err != nil { - return false - } - - for _, inbound := range inbounds { - if inbound.Settings == "" { - continue - } - - settings := map[string][]model.Client{} - _ = json.Unmarshal([]byte(inbound.Settings), &settings) - clients := settings["clients"] - - for _, client := range clients { - limitIp := client.LimitIP - if limitIp > 0 { - return true - } - } - } - - return false + var probe int64 + err := db.Model(&model.ClientRecord{}).Where("limit_ip > 0").Limit(1).Count(&probe).Error + return err == nil && probe > 0 } // processObserved runs collection + enforcement for one scan's observations diff --git a/internal/web/job/check_client_ip_job_integration_test.go b/internal/web/job/check_client_ip_job_integration_test.go index ffa27ea04..e28b9cc04 100644 --- a/internal/web/job/check_client_ip_job_integration_test.go +++ b/internal/web/job/check_client_ip_job_integration_test.go @@ -388,3 +388,28 @@ func TestGetInboundByEmailRejectsSubstringFallbackMatch(t *testing.T) { t.Fatalf("substring email matched inbound %d; want no exact match", got.Id) } } + +// hasLimitIp gates every 10s scan on the normalized clients table: a bare +// "limitIp":0 in settings JSON (which the old LIKE scan matched and parsed) +// must not enable enforcement, while a single clients.limit_ip > 0 row must. +func TestHasLimitIp_ProbesClientRecords(t *testing.T) { + setupIntegrationDB(t) + j := &CheckClientIpJob{} + + if j.hasLimitIp() { + t.Fatal("hasLimitIp = true on an empty database") + } + + seedLinkedInboundWithClient(t, "no-limit", "nolimit@example.com", 0) + if j.hasLimitIp() { + t.Fatal("hasLimitIp = true with only limit_ip=0 clients") + } + + limited := &model.ClientRecord{Email: "limited@example.com", LimitIP: 2} + if err := database.GetDB().Create(limited).Error; err != nil { + t.Fatalf("seed limited client: %v", err) + } + if !j.hasLimitIp() { + t.Fatal("hasLimitIp = false with a limit_ip=2 client present") + } +}