mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 00:25:04 +03:00
fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres (#6612)
* fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres Two writers RMW the same ips JSON blob; on PostgreSQL a lost update drops remote IPs that partitionLiveIps only sees through that blob (#6587). Compare-and-set on the previous blob with re-merge on miss, matching the repo's conditional Where+RowsAffected pattern. Fixes #6587 * ci: retrigger frontend after npm registry maintenance The frontend job failed solely on `npm audit` while registry.npmjs.org returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite build, and storybook all passed. Local `npm audit --omit=dev --audit-level=high` now reports 0 vulnerabilities. * test(ip-limit): cover the scan's CAS against a mid-scan node sync The job-side compare-and-set had no test of its own. A write injected between the scan's read and its update now has to keep the node's remote IP; main's blind Save drops it. Also keeps the new comments to two lines, as CLAUDE.md requires. --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// A node sync landing between the scan's read and its write must keep its remote
|
||||
// IP (#6587); the hook injects that write, since SQLite serializes real writers.
|
||||
func TestProcessObserved_KeepsNodeSyncWriteThatLandsMidScan(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const email = "cas-scan@x"
|
||||
seedLinkedInboundWithClient(t, "cas-scan", email, 3)
|
||||
now := time.Now().Unix()
|
||||
seedClientIps(t, email, []IPWithTimestamp{{IP: "198.51.100.1", Timestamp: now - 60}})
|
||||
|
||||
nodeBlob, err := json.Marshal([]IPWithTimestamp{
|
||||
{IP: "198.51.100.1", Timestamp: now - 60},
|
||||
{IP: "203.0.113.77", Timestamp: now - 5},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal node blob: %v", err)
|
||||
}
|
||||
const callback = "test:client_ip_scan_cas_inject"
|
||||
injected := false
|
||||
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||
if injected || tx.Statement.Schema == nil || tx.Statement.Schema.Table != "inbound_client_ips" {
|
||||
return
|
||||
}
|
||||
injected = true
|
||||
if err := tx.Session(&gorm.Session{SkipHooks: true, NewDB: true}).
|
||||
Model(&model.InboundClientIps{}).
|
||||
Where("client_email = ?", email).
|
||||
Update("ips", string(nodeBlob)).Error; err != nil {
|
||||
_ = tx.AddError(err)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register callback: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Update().Remove(callback) })
|
||||
|
||||
NewCheckClientIpJob().processObserved(map[string]map[string]int64{
|
||||
email: {"198.51.100.1": now},
|
||||
}, true, true)
|
||||
if !injected {
|
||||
t.Fatal("inject callback never fired; the scan's write path is untested")
|
||||
}
|
||||
|
||||
got := ipSet(readClientIps(t, email))
|
||||
if _, ok := got["203.0.113.77"]; !ok {
|
||||
t.Fatalf("scan overwrote the node's remote IP (the #6587 lost update): %v", got)
|
||||
}
|
||||
if got["198.51.100.1"] != now {
|
||||
t.Fatalf("scan's own observation missing or stale: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -510,43 +510,58 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
// Parse old IPs from database
|
||||
var oldIpsWithTime []IPWithTimestamp
|
||||
if inboundClientIps.Ips != "" {
|
||||
_ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
||||
}
|
||||
|
||||
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds, observedAreLive)
|
||||
|
||||
// only ips seen in this scan count toward the limit. see
|
||||
// partitionLiveIps.
|
||||
observedThisScan := make(map[string]bool, len(newIpsWithTime))
|
||||
for _, ipTime := range newIpsWithTime {
|
||||
observedThisScan[ipTime.IP] = true
|
||||
}
|
||||
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
|
||||
staleCutoff := time.Now().Unix() - ipStaleAfterSeconds
|
||||
|
||||
// historical db-only ips are excluded from this count on purpose.
|
||||
limitedIps, allowedIps := j.allowlist.split(liveIps)
|
||||
keptLive, bannedLive := selectIpsToBan(limitedIps, limitIp)
|
||||
// Allowlisted addresses stay connected and out of the count: charging them
|
||||
// against the limit would still cut the shared network the entry protects.
|
||||
keptLive = append(keptLive, allowedIps...)
|
||||
// Node sync merges into the same blob (#6587): compare-and-set it, and on a
|
||||
// miss re-read and re-merge so neither writer drops the other's IPs.
|
||||
for range service.ClientIpCasRetries {
|
||||
var oldIpsWithTime []IPWithTimestamp
|
||||
if inboundClientIps.Ips != "" {
|
||||
_ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
||||
}
|
||||
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, staleCutoff, observedAreLive)
|
||||
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
|
||||
|
||||
// keep kept-live + historical in the blob so the panel keeps showing recently
|
||||
// seen ips; banned live ips reappear in the next scan if they reconnect.
|
||||
dbIps := make([]IPWithTimestamp, 0, len(keptLive)+len(historicalIps))
|
||||
dbIps = append(dbIps, keptLive...)
|
||||
dbIps = append(dbIps, historicalIps...)
|
||||
jsonIps, _ := json.Marshal(dbIps)
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
// historical db-only ips are excluded from this count on purpose.
|
||||
limitedIps, allowedIps := j.allowlist.split(liveIps)
|
||||
keptLive, bannedLive := selectIpsToBan(limitedIps, limitIp)
|
||||
// Allowlisted addresses stay connected and out of the count: charging them
|
||||
// against the limit would still cut the shared network the entry protects.
|
||||
keptLive = append(keptLive, allowedIps...)
|
||||
|
||||
if err := tx.Save(inboundClientIps).Error; err != nil {
|
||||
logger.Error("failed to save inboundClientIps:", err)
|
||||
return nil, 0
|
||||
// keep kept-live + historical in the blob so the panel keeps showing recently
|
||||
// seen ips; banned live ips reappear in the next scan if they reconnect.
|
||||
dbIps := make([]IPWithTimestamp, 0, len(keptLive)+len(historicalIps))
|
||||
dbIps = append(dbIps, keptLive...)
|
||||
dbIps = append(dbIps, historicalIps...)
|
||||
jsonIps, _ := json.Marshal(dbIps)
|
||||
newIps := string(jsonIps)
|
||||
if newIps == inboundClientIps.Ips {
|
||||
return bannedLive, len(keptLive)
|
||||
}
|
||||
|
||||
updated, err := service.CasUpdateInboundClientIps(tx, inboundClientIps.Id, inboundClientIps.Ips, newIps)
|
||||
if err != nil {
|
||||
logger.Error("failed to save inboundClientIps:", err)
|
||||
return nil, 0
|
||||
}
|
||||
if updated {
|
||||
inboundClientIps.Ips = newIps
|
||||
return bannedLive, len(keptLive)
|
||||
}
|
||||
if err := tx.Where("id = ?", inboundClientIps.Id).First(inboundClientIps).Error; err != nil {
|
||||
logger.Error("failed to re-read inboundClientIps after a concurrent write:", err)
|
||||
return nil, 0
|
||||
}
|
||||
}
|
||||
|
||||
return bannedLive, len(keptLive)
|
||||
logger.Error("failed to save inboundClientIps: exhausted CAS retries")
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
// pendingBan carries one client's enforcement outcome from inside the scan's
|
||||
|
||||
Reference in New Issue
Block a user