fix(job): gate ip-limit scan on clients.limit_ip instead of parsing all settings

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.
This commit is contained in:
MHSanaei
2026-07-02 16:24:18 +02:00
parent 97588dd0b9
commit c3cc8b4374
2 changed files with 32 additions and 25 deletions
+7 -25
View File
@@ -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
@@ -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")
}
}