mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 16:45:03 +03:00
perf(ip-sync): avoid redundant client IP reads and writes (#6930)
The access-log scanner and the cross-node merge stored the same IP list in opposite orders, so neither saw a row as unchanged, and the merge re-read every preloaded row and re-upserted every attribution row each sync cycle. All writers now share one canonical encoder (timestamp, then IP ascending), the first CAS is seeded from the preload, and identical attribution rows are skipped. A create race between the scanner and node sync now merges into the winning row instead of dropping the loser's IPs, which on PostgreSQL also aborted the scanner's whole scan transaction. Closes #6927
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -86,7 +87,7 @@ func TestProcessObserved_CommitFailureDoesNotPublishBan(t *testing.T) {
|
||||
const email = "rollback-must-not-ban@x"
|
||||
seedLinkedInboundWithClient(t, "rollback-must-not-ban", email, 1)
|
||||
now := time.Now().Unix()
|
||||
seedClientIps(t, email, []IPWithTimestamp{{IP: "198.51.100.10", Timestamp: now - 2}})
|
||||
seedClientIps(t, email, []model.ClientIpEntry{{IP: "198.51.100.10", Timestamp: now - 2}})
|
||||
|
||||
installClientIpCommitFailure(t)
|
||||
|
||||
@@ -146,7 +147,7 @@ func TestProcessObserved_ForgetsBannedSeenWhenClientReturnsUnderLimit(t *testing
|
||||
const email = "prune-banned-seen@x"
|
||||
seedLinkedInboundWithClient(t, "prune-banned-seen", email, 1)
|
||||
now := time.Now().Unix()
|
||||
seedClientIps(t, email, []IPWithTimestamp{{IP: "203.0.113.1", Timestamp: now - 500}})
|
||||
seedClientIps(t, email, []model.ClientIpEntry{{IP: "203.0.113.1", Timestamp: now - 500}})
|
||||
j := NewCheckClientIpJob()
|
||||
|
||||
j.processObserved(map[string]map[string]int64{
|
||||
@@ -180,7 +181,7 @@ func (f failingWriter) Write([]byte) (int, error) { return 0, f.err }
|
||||
// sees, so it has to reach the caller.
|
||||
func TestWriteBanLinesSurfacesWriteFailure(t *testing.T) {
|
||||
want := errors.New("no space left on device")
|
||||
err := writeBanLines(failingWriter{err: want}, "write-failure@x", []IPWithTimestamp{
|
||||
err := writeBanLines(failingWriter{err: want}, "write-failure@x", []model.ClientIpEntry{
|
||||
{IP: "203.0.113.9", Timestamp: time.Now().Unix()},
|
||||
})
|
||||
if !errors.Is(err, want) {
|
||||
@@ -195,7 +196,7 @@ func TestProcessObserved_PublishesBanForCommittedScan(t *testing.T) {
|
||||
const email = "published-ban@x"
|
||||
seedLinkedInboundWithClient(t, "published-ban", email, 1)
|
||||
now := time.Now().Unix()
|
||||
seedClientIps(t, email, []IPWithTimestamp{{IP: "203.0.113.50", Timestamp: now - 500}})
|
||||
seedClientIps(t, email, []model.ClientIpEntry{{IP: "203.0.113.50", Timestamp: now - 500}})
|
||||
|
||||
j := NewCheckClientIpJob()
|
||||
if cleaned := j.processObserved(map[string]map[string]int64{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -9,20 +9,26 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// 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)
|
||||
processObservedKeepsNodeSyncWrite(t)
|
||||
}
|
||||
|
||||
func processObservedKeepsNodeSyncWrite(t *testing.T) {
|
||||
t.Helper()
|
||||
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}})
|
||||
seed := seedClientIps(t, email, []model.ClientIpEntry{{IP: "198.51.100.1", Timestamp: now - 60}})
|
||||
|
||||
nodeBlob, err := json.Marshal([]IPWithTimestamp{
|
||||
nodeBlob, err := model.MarshalClientIpEntries([]model.ClientIpEntry{
|
||||
{IP: "198.51.100.1", Timestamp: now - 60},
|
||||
{IP: "203.0.113.77", Timestamp: now - 5},
|
||||
})
|
||||
@@ -35,11 +41,19 @@ func TestProcessObserved_KeepsNodeSyncWriteThatLandsMidScan(t *testing.T) {
|
||||
if injected || tx.Statement.Schema == nil || tx.Statement.Schema.Table != "inbound_client_ips" {
|
||||
return
|
||||
}
|
||||
where, ok := tx.Statement.Clauses["WHERE"].Expression.(clause.Where)
|
||||
if !ok || len(where.Exprs) != 1 {
|
||||
return
|
||||
}
|
||||
predicate, ok := where.Exprs[0].(clause.Expr)
|
||||
if !ok || len(predicate.Vars) != 2 || predicate.Vars[0] != seed.Id {
|
||||
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 {
|
||||
Update("ips", nodeBlob).Error; err != nil {
|
||||
_ = tx.AddError(err)
|
||||
}
|
||||
}); err != nil {
|
||||
@@ -55,10 +69,8 @@ func TestProcessObserved_KeepsNodeSyncWriteThatLandsMidScan(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
want := map[string]int64{"203.0.113.77": now - 5, "198.51.100.1": now}
|
||||
if !maps.Equal(got, want) {
|
||||
t.Fatalf("scan/node union = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func banLineCount(t *testing.T, email string) int {
|
||||
@@ -27,7 +29,7 @@ func TestUpdateInboundClientIps_FrozenLastSeenBannedOnce(t *testing.T) {
|
||||
|
||||
now := time.Now().Unix()
|
||||
deadStart := now - 300
|
||||
live := []IPWithTimestamp{
|
||||
live := []model.ClientIpEntry{
|
||||
{IP: "10.2.0.1", Timestamp: deadStart},
|
||||
{IP: "192.0.2.7", Timestamp: now},
|
||||
}
|
||||
@@ -53,7 +55,7 @@ func TestUpdateInboundClientIps_FrozenLastSeenBannedOnce(t *testing.T) {
|
||||
t.Fatalf("ban lines after frozen rescan = %d, want still 1", got)
|
||||
}
|
||||
|
||||
reconnected := []IPWithTimestamp{
|
||||
reconnected := []model.ClientIpEntry{
|
||||
{IP: "10.2.0.1", Timestamp: now + 30},
|
||||
{IP: "192.0.2.7", Timestamp: now + 60},
|
||||
}
|
||||
|
||||
@@ -21,14 +21,9 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// IPWithTimestamp tracks an IP address with its last seen timestamp
|
||||
type IPWithTimestamp struct {
|
||||
IP string `json:"ip"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// onlineUsersSource is the one service.XrayService method the scan needs, so
|
||||
// tests can drive Run end to end without a live xray core.
|
||||
type onlineUsersSource interface {
|
||||
@@ -352,14 +347,12 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to IPWithTimestamp slice
|
||||
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
||||
ipsWithTime := make([]model.ClientIpEntry, 0, len(ipTimestamps))
|
||||
attrEntries := make([]model.ClientIpEntry, 0, len(ipTimestamps))
|
||||
for ip, timestamp := range ipTimestamps {
|
||||
ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
||||
// Live API observations may carry an old lastSeen (connection start),
|
||||
// so stamp attribution with now; otherwise the stale cutoff would evict
|
||||
// an IP that is connected right now.
|
||||
ipsWithTime = append(ipsWithTime, model.ClientIpEntry{IP: ip, Timestamp: timestamp})
|
||||
// A live connection's lastSeen may be its old start time.
|
||||
// Stamp attribution now so retention cannot evict a connected IP.
|
||||
attrTs := timestamp
|
||||
if observedAreLive {
|
||||
attrTs = now
|
||||
@@ -372,15 +365,27 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
|
||||
|
||||
clientIpsRecord, ok := ipRowByEmail[email]
|
||||
if !ok {
|
||||
jsonIps, err := json.Marshal(ipsWithTime)
|
||||
jsonIps, err := model.MarshalClientIpEntries(ipsWithTime)
|
||||
if err != nil {
|
||||
j.checkError(err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Save(&model.InboundClientIps{ClientEmail: email, Ips: string(jsonIps)}).Error; err != nil {
|
||||
clientIpsRecord = &model.InboundClientIps{ClientEmail: email, Ips: jsonIps}
|
||||
result := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client_email"}},
|
||||
DoNothing: true,
|
||||
}).Create(clientIpsRecord)
|
||||
if result.Error != nil {
|
||||
j.checkError(result.Error)
|
||||
continue
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("client_email = ?", email).First(clientIpsRecord).Error; err != nil {
|
||||
j.checkError(err)
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
candidates, keptLive := j.updateInboundClientIps(tx, clientIpsRecord, inbound, email, limitByEmail[email], ipsWithTime, enforce, observedAreLive)
|
||||
@@ -429,11 +434,9 @@ func (j *CheckClientIpJob) recordLocalAttribution(attribution map[string][]model
|
||||
}
|
||||
}
|
||||
|
||||
// mergeClientIps folds this scan's observations into the persisted set,
|
||||
// dropping entries older than staleCutoff. newAlwaysLive exempts the new
|
||||
// entries from that cutoff: an API-observed IP is a live connection by
|
||||
// definition, even when its lastSeen (set at dispatch time) is hours old.
|
||||
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64, newAlwaysLive bool) map[string]int64 {
|
||||
// mergeClientIps retains fresh entries and exempts currently observed live
|
||||
// connections from expiry even when their dispatch timestamps are old.
|
||||
func mergeClientIps(old, new []model.ClientIpEntry, staleCutoff int64, newAlwaysLive bool) map[string]int64 {
|
||||
ipMap := make(map[string]int64, len(old)+len(new))
|
||||
for _, ipTime := range old {
|
||||
if ipTime.Timestamp < staleCutoff {
|
||||
@@ -454,7 +457,7 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64, newAlwaysLive
|
||||
|
||||
// selectIpsToBan splits the live IPs (sorted oldest-first by partitionLiveIps)
|
||||
// into the newest `limit` entries to keep and the older remainder to ban.
|
||||
func selectIpsToBan(live []IPWithTimestamp, limit int) (kept, banned []IPWithTimestamp) {
|
||||
func selectIpsToBan(live []model.ClientIpEntry, limit int) (kept, banned []model.ClientIpEntry) {
|
||||
if limit <= 0 || len(live) <= limit {
|
||||
return live, nil
|
||||
}
|
||||
@@ -462,33 +465,21 @@ func selectIpsToBan(live []IPWithTimestamp, limit int) (kept, banned []IPWithTim
|
||||
return live[cutoff:], live[:cutoff]
|
||||
}
|
||||
|
||||
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
||||
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
||||
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
||||
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []model.ClientIpEntry) {
|
||||
live = make([]model.ClientIpEntry, 0, len(observedThisScan))
|
||||
historical = make([]model.ClientIpEntry, 0, len(ipMap))
|
||||
now := time.Now().Unix()
|
||||
for ip, ts := range ipMap {
|
||||
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
|
||||
// Consider an IP "live" if it was seen locally in this scan, OR if its
|
||||
// timestamp from the synced database is very recent (e.g. within 2 minutes).
|
||||
// This ensures cluster-wide limits work even if the IP was seen on another node.
|
||||
entry := model.ClientIpEntry{IP: ip, Timestamp: ts}
|
||||
// Recent remote observations count toward the cluster-wide limit.
|
||||
if observedThisScan[ip] || now-ts < 120 {
|
||||
live = append(live, entry)
|
||||
} else {
|
||||
historical = append(historical, entry)
|
||||
}
|
||||
}
|
||||
sort.Slice(live, func(i, j int) bool {
|
||||
if live[i].Timestamp != live[j].Timestamp {
|
||||
return live[i].Timestamp < live[j].Timestamp
|
||||
}
|
||||
return live[i].IP < live[j].IP
|
||||
})
|
||||
sort.Slice(historical, func(i, j int) bool {
|
||||
if historical[i].Timestamp != historical[j].Timestamp {
|
||||
return historical[i].Timestamp < historical[j].Timestamp
|
||||
}
|
||||
return historical[i].IP < historical[j].IP
|
||||
})
|
||||
model.SortClientIpEntries(live)
|
||||
model.SortClientIpEntries(historical)
|
||||
return live, historical
|
||||
}
|
||||
|
||||
@@ -523,10 +514,8 @@ func (j *CheckClientIpJob) delInboundClientIps(tx *gorm.DB, clientEmail string)
|
||||
}
|
||||
}
|
||||
|
||||
// updateInboundClientIps merges one email's observed IPs into its tracking row
|
||||
// and applies the IP limit. Ban candidates are returned, not written: the
|
||||
// fail2ban log is the point of no return and must wait for the commit.
|
||||
func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) (banCandidates []IPWithTimestamp, keptLiveCount int) {
|
||||
// updateInboundClientIps returns ban candidates; publication must wait for commit.
|
||||
func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []model.ClientIpEntry, enforce, observedAreLive bool) (banCandidates []model.ClientIpEntry, keptLiveCount int) {
|
||||
if inbound.Settings == "" {
|
||||
logger.Debug("wrong data:", inbound)
|
||||
return nil, 0
|
||||
@@ -545,14 +534,14 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
|
||||
// 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
|
||||
var oldIpsWithTime []model.ClientIpEntry
|
||||
if inboundClientIps.Ips != "" {
|
||||
_ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
||||
}
|
||||
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, staleCutoff, observedAreLive)
|
||||
|
||||
dbIps := make([]IPWithTimestamp, 0, len(ipMap))
|
||||
var bannedLive []IPWithTimestamp
|
||||
dbIps := make([]model.ClientIpEntry, 0, len(ipMap))
|
||||
var bannedLive []model.ClientIpEntry
|
||||
keptLive := 0
|
||||
if enforceLimit {
|
||||
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
|
||||
@@ -569,12 +558,16 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
|
||||
dbIps = append(dbIps, kept...)
|
||||
dbIps = append(dbIps, historicalIps...)
|
||||
} else {
|
||||
// Collection-only run (#6722): no ban selection; keep every IP in
|
||||
// the 30-minute window, sorted so unchanged scans skip the write.
|
||||
dbIps = sortedIpsFromMap(ipMap)
|
||||
// Collection-only run (#6722): no ban selection, keep every IP in the window.
|
||||
for ip, timestamp := range ipMap {
|
||||
dbIps = append(dbIps, model.ClientIpEntry{IP: ip, Timestamp: timestamp})
|
||||
}
|
||||
}
|
||||
newIps, err := model.MarshalClientIpEntries(dbIps)
|
||||
if err != nil {
|
||||
j.checkError(err)
|
||||
return nil, 0
|
||||
}
|
||||
jsonIps, _ := json.Marshal(dbIps)
|
||||
newIps := string(jsonIps)
|
||||
if newIps == inboundClientIps.Ips {
|
||||
return bannedLive, keptLive
|
||||
}
|
||||
@@ -597,28 +590,12 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
// sortedIpsFromMap orders the merged IP set by timestamp with the IP as
|
||||
// tie-breaker, giving a stable blob for the skip-when-unchanged compare.
|
||||
func sortedIpsFromMap(ipMap map[string]int64) []IPWithTimestamp {
|
||||
out := make([]IPWithTimestamp, 0, len(ipMap))
|
||||
for ip, ts := range ipMap {
|
||||
out = append(out, IPWithTimestamp{IP: ip, Timestamp: ts})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Timestamp != out[j].Timestamp {
|
||||
return out[i].Timestamp < out[j].Timestamp
|
||||
}
|
||||
return out[i].IP < out[j].IP
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// pendingBan carries one client's enforcement outcome from inside the scan's
|
||||
// transaction to the publication that may only follow a successful commit.
|
||||
type pendingBan struct {
|
||||
inbound *model.Inbound
|
||||
email string
|
||||
candidates []IPWithTimestamp
|
||||
candidates []model.ClientIpEntry
|
||||
keptLive int
|
||||
}
|
||||
|
||||
@@ -662,7 +639,7 @@ func (j *CheckClientIpJob) publishBans(bans []pendingBan) []pendingBan {
|
||||
|
||||
// writeBanLines emits one line per address; the wording is load-bearing, since
|
||||
// x-ui.sh create_iplimit_jails builds filter.d/3x-ipl.conf failregex from it.
|
||||
func writeBanLines(w io.Writer, clientEmail string, actionable []IPWithTimestamp) error {
|
||||
func writeBanLines(w io.Writer, clientEmail string, actionable []model.ClientIpEntry) error {
|
||||
stamp := time.Now().Format("2006/01/02 15:04:05")
|
||||
for _, ipTime := range actionable {
|
||||
if _, err := fmt.Fprintf(w, "%s [LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d\n",
|
||||
@@ -675,8 +652,8 @@ func writeBanLines(w io.Writer, clientEmail string, actionable []IPWithTimestamp
|
||||
|
||||
// selectAdvancedSinceLastBan drops pairs with a frozen lastSeen: the core
|
||||
// refreshes it only on a new dispatch, so those are unreaped dead connections.
|
||||
func (j *CheckClientIpJob) selectAdvancedSinceLastBan(email string, banned []IPWithTimestamp) []IPWithTimestamp {
|
||||
actionable := make([]IPWithTimestamp, 0, len(banned))
|
||||
func (j *CheckClientIpJob) selectAdvancedSinceLastBan(email string, banned []model.ClientIpEntry) []model.ClientIpEntry {
|
||||
actionable := make([]model.ClientIpEntry, 0, len(banned))
|
||||
for _, ipTime := range banned {
|
||||
if last, ok := j.bannedSeen[email+"|"+ipTime.IP]; ok && ipTime.Timestamp <= last {
|
||||
continue
|
||||
@@ -688,7 +665,7 @@ func (j *CheckClientIpJob) selectAdvancedSinceLastBan(email string, banned []IPW
|
||||
|
||||
// recordBannedSeen marks published pairs and forgets addresses this scan no
|
||||
// longer bans; it runs for every enforced client, which is what prunes the map.
|
||||
func (j *CheckClientIpJob) recordBannedSeen(email string, banned, published []IPWithTimestamp) {
|
||||
func (j *CheckClientIpJob) recordBannedSeen(email string, banned, published []model.ClientIpEntry) {
|
||||
if j.bannedSeen == nil {
|
||||
j.bannedSeen = make(map[string]int64)
|
||||
}
|
||||
|
||||
@@ -22,10 +22,7 @@ import (
|
||||
// log a warning. otherwise log.Warningf panics on a nil logger.
|
||||
var loggerInitOnce sync.Once
|
||||
|
||||
// setupIntegrationDB wires a temp sqlite db and log folder so
|
||||
// updateInboundClientIps can run end to end. closes the db before
|
||||
// TempDir cleanup so windows doesn't complain about the file being in
|
||||
// use.
|
||||
// setupIntegrationDB closes its temporary SQLite database before directory cleanup.
|
||||
|
||||
func setupIntegrationDB(t *testing.T) {
|
||||
t.Helper()
|
||||
@@ -38,6 +35,8 @@ func setupIntegrationDB(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
t.Setenv("XUI_DB_TYPE", "sqlite")
|
||||
t.Setenv("XUI_DB_DSN", "")
|
||||
t.Setenv("XUI_LOG_FOLDER", logDir)
|
||||
|
||||
// updateInboundClientIps calls log.SetOutput on the package global,
|
||||
@@ -54,7 +53,7 @@ func setupIntegrationDB(t *testing.T) {
|
||||
|
||||
// enforceIpLimitForTest runs the same two steps processObserved does: select
|
||||
// inside the transaction, publish only once it would have committed.
|
||||
func (j *CheckClientIpJob) enforceIpLimitForTest(t *testing.T, row *model.InboundClientIps, inbound *model.Inbound, email string, limit int, live []IPWithTimestamp, observedAreLive bool) (banned bool, published []IPWithTimestamp) {
|
||||
func (j *CheckClientIpJob) enforceIpLimitForTest(t *testing.T, row *model.InboundClientIps, inbound *model.Inbound, email string, limit int, live []model.ClientIpEntry, observedAreLive bool) (banned bool, published []model.ClientIpEntry) {
|
||||
t.Helper()
|
||||
candidates, keptLive := j.updateInboundClientIps(database.GetDB(), row, inbound, email, limit, live, true, observedAreLive)
|
||||
actionable := j.selectAdvancedSinceLastBan(email, candidates)
|
||||
@@ -115,15 +114,15 @@ func seedLinkedInboundWithClient(t *testing.T, tag, email string, limitIp int) *
|
||||
}
|
||||
|
||||
// seed an InboundClientIps row with the given blob.
|
||||
func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.InboundClientIps {
|
||||
func seedClientIps(t *testing.T, email string, ips []model.ClientIpEntry) *model.InboundClientIps {
|
||||
t.Helper()
|
||||
blob, err := json.Marshal(ips)
|
||||
blob, err := model.MarshalClientIpEntries(ips)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal ips: %v", err)
|
||||
}
|
||||
row := &model.InboundClientIps{
|
||||
ClientEmail: email,
|
||||
Ips: string(blob),
|
||||
Ips: blob,
|
||||
}
|
||||
if err := database.GetDB().Create(row).Error; err != nil {
|
||||
t.Fatalf("seed InboundClientIps: %v", err)
|
||||
@@ -132,7 +131,7 @@ func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.Inb
|
||||
}
|
||||
|
||||
// read the persisted blob and parse it back.
|
||||
func readClientIps(t *testing.T, email string) []IPWithTimestamp {
|
||||
func readClientIps(t *testing.T, email string) []model.ClientIpEntry {
|
||||
t.Helper()
|
||||
row := &model.InboundClientIps{}
|
||||
if err := database.GetDB().Where("client_email = ?", email).First(row).Error; err != nil {
|
||||
@@ -141,7 +140,7 @@ func readClientIps(t *testing.T, email string) []IPWithTimestamp {
|
||||
if row.Ips == "" {
|
||||
return nil
|
||||
}
|
||||
var out []IPWithTimestamp
|
||||
var out []model.ClientIpEntry
|
||||
if err := json.Unmarshal([]byte(row.Ips), &out); err != nil {
|
||||
t.Fatalf("unmarshal Ips blob %q: %v", row.Ips, err)
|
||||
}
|
||||
@@ -149,7 +148,7 @@ func readClientIps(t *testing.T, email string) []IPWithTimestamp {
|
||||
}
|
||||
|
||||
// make a lookup map so asserts don't depend on slice order.
|
||||
func ipSet(entries []IPWithTimestamp) map[string]int64 {
|
||||
func ipSet(entries []model.ClientIpEntry) map[string]int64 {
|
||||
out := make(map[string]int64, len(entries))
|
||||
for _, e := range entries {
|
||||
out[e.IP] = e.Timestamp
|
||||
@@ -198,7 +197,7 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
|
||||
|
||||
now := time.Now().Unix()
|
||||
// idle but still within the 30min staleness window.
|
||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
||||
row := seedClientIps(t, email, []model.ClientIpEntry{
|
||||
{IP: "10.0.0.1", Timestamp: now - 20*60},
|
||||
{IP: "10.0.0.2", Timestamp: now - 15*60},
|
||||
{IP: "10.0.0.3", Timestamp: now - 10*60},
|
||||
@@ -206,7 +205,7 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
|
||||
|
||||
j := NewCheckClientIpJob()
|
||||
// the one that's actually connecting (user's 128.71.x.x).
|
||||
live := []IPWithTimestamp{
|
||||
live := []model.ClientIpEntry{
|
||||
{IP: "128.71.1.1", Timestamp: now},
|
||||
}
|
||||
|
||||
@@ -250,7 +249,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
||||
seedInboundWithClient(t, "inbound-pr4091-abuse", email, 1)
|
||||
|
||||
now := time.Now().Unix()
|
||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
||||
row := seedClientIps(t, email, []model.ClientIpEntry{
|
||||
{IP: "10.1.0.1", Timestamp: now - 60}, // original connection
|
||||
})
|
||||
|
||||
@@ -258,7 +257,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
||||
// both live, limit=1. use distinct timestamps so sort-by-timestamp
|
||||
// is deterministic: 10.1.0.1 is the original (older) and must get
|
||||
// banned; 192.0.2.9 joined later and keeps the slot (last IP wins).
|
||||
live := []IPWithTimestamp{
|
||||
live := []model.ClientIpEntry{
|
||||
{IP: "10.1.0.1", Timestamp: now - 5},
|
||||
{IP: "192.0.2.9", Timestamp: now},
|
||||
}
|
||||
@@ -329,7 +328,7 @@ func TestProcessObserved_CollectionOnlyKeepsRecentWindow(t *testing.T) {
|
||||
const email = "window-user"
|
||||
seedInboundWithClient(t, "inbound-window", email, 0)
|
||||
fiveMinAgo := time.Now().Unix() - 5*60
|
||||
seedClientIps(t, email, []IPWithTimestamp{{IP: "198.51.100.1", Timestamp: fiveMinAgo}})
|
||||
seedClientIps(t, email, []model.ClientIpEntry{{IP: "198.51.100.1", Timestamp: fiveMinAgo}})
|
||||
|
||||
observed := map[string]map[string]int64{
|
||||
email: {"203.0.113.30": time.Now().Unix()},
|
||||
@@ -391,7 +390,7 @@ func TestProcessObserved_StaleEmailIsSkippedAndOrphanDropped(t *testing.T) {
|
||||
const staleEmail = "renamed-away"
|
||||
// No inbound references staleEmail. Pre-seed an orphan tracking row to
|
||||
// confirm the job removes it rather than leaving it to error forever.
|
||||
seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
|
||||
seedClientIps(t, staleEmail, []model.ClientIpEntry{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
|
||||
|
||||
observed := map[string]map[string]int64{
|
||||
staleEmail: {"203.0.113.5": time.Now().Unix()},
|
||||
@@ -493,14 +492,14 @@ func TestUpdateInboundClientIps_AllowlistedIpIsNeitherCountedNorBanned(t *testin
|
||||
seedInboundWithClient(t, "inbound-issue5378", email, 1)
|
||||
|
||||
now := time.Now().Unix()
|
||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
||||
row := seedClientIps(t, email, []model.ClientIpEntry{
|
||||
{IP: "203.0.113.10", Timestamp: now - 60},
|
||||
})
|
||||
|
||||
j := NewCheckClientIpJob()
|
||||
j.allowlist = parseIpLimitAllowlist("203.0.113.0/24")
|
||||
|
||||
live := []IPWithTimestamp{
|
||||
live := []model.ClientIpEntry{
|
||||
{IP: "203.0.113.10", Timestamp: now - 5},
|
||||
{IP: "192.0.2.9", Timestamp: now},
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
||||
// banned again immediately because a long-disconnected IP stayed in the
|
||||
// DB with an ancient timestamp and kept "protecting" itself against
|
||||
// eviction. Guard against that regression here.
|
||||
old := []IPWithTimestamp{
|
||||
old := []model.ClientIpEntry{
|
||||
{IP: "1.1.1.1", Timestamp: 100}, // stale — client disconnected long ago
|
||||
{IP: "2.2.2.2", Timestamp: 1900}, // fresh — still connecting
|
||||
}
|
||||
new := []IPWithTimestamp{
|
||||
new := []model.ClientIpEntry{
|
||||
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
||||
func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
|
||||
// Backwards-compat: entries that aren't stale are still carried forward,
|
||||
// so enforcement survives access-log rotation.
|
||||
old := []IPWithTimestamp{
|
||||
old := []model.ClientIpEntry{
|
||||
{IP: "1.1.1.1", Timestamp: 1500},
|
||||
}
|
||||
got := mergeClientIps(old, nil, 1000, false)
|
||||
@@ -49,8 +49,8 @@ func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
|
||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
|
||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
|
||||
old := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 1500}}
|
||||
new := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 1700}}
|
||||
|
||||
got := mergeClientIps(old, new, 1000, false)
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
|
||||
func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
|
||||
// A log line with a clock-skewed old timestamp must not resurrect a
|
||||
// stale IP past the cutoff.
|
||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
|
||||
new := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 500}}
|
||||
got := mergeClientIps(nil, new, 1000, false)
|
||||
|
||||
if len(got) != 0 {
|
||||
@@ -73,8 +73,8 @@ func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
|
||||
func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
|
||||
// Defensive: a zero cutoff (e.g. during very first run on a fresh
|
||||
// install) must not over-evict.
|
||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
|
||||
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
|
||||
old := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 100}}
|
||||
new := []model.ClientIpEntry{{IP: "2.2.2.2", Timestamp: 200}}
|
||||
|
||||
got := mergeClientIps(old, new, 0, false)
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestMergeClientIps_LiveObservationsBypassStaleCutoff(t *testing.T) {
|
||||
// online-API mode: lastSeen is set when the connection was dispatched, so
|
||||
// a connection held open for hours has an "old" timestamp while being live
|
||||
// by definition. It must survive the stale cutoff.
|
||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}} // opened long ago, still connected
|
||||
new := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 500}} // opened long ago, still connected
|
||||
got := mergeClientIps(nil, new, 1000, true)
|
||||
|
||||
want := map[string]int64{"1.1.1.1": 500}
|
||||
@@ -100,8 +100,8 @@ func TestMergeClientIps_LiveObservationsBypassStaleCutoff(t *testing.T) {
|
||||
func TestMergeClientIps_LiveModeStillEvictsStaleOldEntries(t *testing.T) {
|
||||
// the bypass applies only to this scan's observations — persisted entries
|
||||
// from past scans still age out as before.
|
||||
old := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 100}}
|
||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 2000}}
|
||||
old := []model.ClientIpEntry{{IP: "2.2.2.2", Timestamp: 100}}
|
||||
new := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 2000}}
|
||||
got := mergeClientIps(old, new, 1000, true)
|
||||
|
||||
want := map[string]int64{"1.1.1.1": 2000}
|
||||
@@ -111,7 +111,7 @@ func TestMergeClientIps_LiveModeStillEvictsStaleOldEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSelectIpsToBan(t *testing.T) {
|
||||
live := []IPWithTimestamp{ // sorted oldest-first, as partitionLiveIps returns
|
||||
live := []model.ClientIpEntry{ // sorted oldest-first, as partitionLiveIps returns
|
||||
{IP: "A", Timestamp: 100},
|
||||
{IP: "B", Timestamp: 200},
|
||||
{IP: "C", Timestamp: 300},
|
||||
@@ -144,7 +144,7 @@ func TestSelectIpsToBan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func collectIps(entries []IPWithTimestamp) []string {
|
||||
func collectIps(entries []model.ClientIpEntry) []string {
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.IP)
|
||||
@@ -294,7 +294,7 @@ func TestCheckFail2BanInstalled_EmptyEnvSkipsClientProbe(t *testing.T) {
|
||||
func TestPartitionLiveIps_TiedTimestampsKeepStableIpOrder(t *testing.T) {
|
||||
ipMap := map[string]int64{"203.0.113.3": 100, "203.0.113.1": 100, "203.0.113.2": 100}
|
||||
observed := map[string]bool{"203.0.113.1": true, "203.0.113.2": true, "203.0.113.3": true}
|
||||
var first []IPWithTimestamp
|
||||
var first []model.ClientIpEntry
|
||||
for i := range 50 {
|
||||
live, _ := partitionLiveIps(ipMap, observed)
|
||||
if i == 0 {
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +21,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
|
||||
)
|
||||
|
||||
// setupScaleJobDB mirrors the service package's scale gating: Postgres via
|
||||
@@ -28,10 +32,15 @@ func setupScaleJobDB(t *testing.T) {
|
||||
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
||||
|
||||
if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
|
||||
cleanup, err := testpg.IsolatePackage("job_ip_scale")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB(postgres): %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
return
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
||||
@@ -164,11 +173,7 @@ func seedScaleIPDataset(t *testing.T, n, numInbounds int) []string {
|
||||
return limitedEmails
|
||||
}
|
||||
|
||||
// TestCheckClientIpScale measures the @every 10s ip-limit job pieces: the
|
||||
// hasLimitIp gate (settings LIKE scan + full JSON parse of every matching
|
||||
// inbound) and processObserved with M online users (per-email inbound lookup,
|
||||
// settings parse and autocommit save). Run twice: first scan half add / half
|
||||
// update, second scan all update path.
|
||||
// TestCheckClientIpScale measures the gate and two scans over an identical fixture.
|
||||
func TestCheckClientIpScale(t *testing.T) {
|
||||
shapes := []struct {
|
||||
name string
|
||||
@@ -201,15 +206,27 @@ func TestCheckClientIpScale(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for i := range m / 2 {
|
||||
seedClientIps(t, limited[i], []IPWithTimestamp{{IP: "10.99.0.1", Timestamp: now - 60}})
|
||||
seedClientIps(t, limited[i], []model.ClientIpEntry{{IP: "10.99.0.1", Timestamp: now - 60}})
|
||||
}
|
||||
|
||||
counts := countScannerScaleSQL(t)
|
||||
before := captureScannerScaleStats(t)
|
||||
var memoryBefore, memoryAfter runtime.MemStats
|
||||
runtime.ReadMemStats(&memoryBefore)
|
||||
start = time.Now()
|
||||
j.processObserved(observed, true, true)
|
||||
firstScan := time.Since(start)
|
||||
firstCounts := counts.snapshot()
|
||||
start = time.Now()
|
||||
j.processObserved(observed, true, true)
|
||||
secondScan := time.Since(start)
|
||||
runtime.ReadMemStats(&memoryAfter)
|
||||
finalCounts := counts.snapshot()
|
||||
t.Logf("first scan SQL flat_SELECTs=%d per_ID_SELECTs=%d CAS_attempts=%d CAS_misses=%d flat_creates=%d flat_affected=%d attribution_SELECTs=%d upsert_attempts=%d attribution_affected=%d", firstCounts[0], firstCounts[1], firstCounts[2], firstCounts[2]-firstCounts[3], firstCounts[4], firstCounts[3]+firstCounts[5], firstCounts[6], firstCounts[7], firstCounts[8])
|
||||
t.Logf("second scan SQL flat_SELECTs=%d per_ID_SELECTs=%d CAS_attempts=%d CAS_misses=%d flat_creates=%d flat_affected=%d attribution_SELECTs=%d upsert_attempts=%d attribution_affected=%d", finalCounts[0]-firstCounts[0], finalCounts[1]-firstCounts[1], finalCounts[2]-firstCounts[2], finalCounts[2]-firstCounts[2]-(finalCounts[3]-firstCounts[3]), finalCounts[4]-firstCounts[4], finalCounts[3]-firstCounts[3]+finalCounts[5]-firstCounts[5], finalCounts[6]-firstCounts[6], finalCounts[7]-firstCounts[7], finalCounts[8]-firstCounts[8])
|
||||
after := captureScannerScaleStats(t)
|
||||
t.Logf("scan allocations=%d bytes=%d", memoryAfter.Mallocs-memoryBefore.Mallocs, memoryAfter.TotalAlloc-memoryBefore.TotalAlloc)
|
||||
logScannerScaleStats(t, before, after)
|
||||
t.Logf("N=%-7d shape=%-8s processObserved M=%-5d first=%-10v second=%-10v (%.1fms/email)",
|
||||
n, shape.name, m, firstScan.Round(time.Millisecond), secondScan.Round(time.Millisecond),
|
||||
float64(secondScan.Milliseconds())/float64(m))
|
||||
@@ -221,7 +238,120 @@ func TestCheckClientIpScale(t *testing.T) {
|
||||
if rows != int64(m) {
|
||||
t.Fatalf("inbound_client_ips rows = %d, want %d", rows, m)
|
||||
}
|
||||
for i := range m {
|
||||
want := map[string]int64{fmt.Sprintf("10.0.%d.%d", i/250, i%250+1): now}
|
||||
if i < m/2 {
|
||||
want["10.99.0.1"] = now - 60
|
||||
}
|
||||
if got := ipSet(readClientIps(t, limited[i])); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("retained %s = %v, want %v", limited[i], got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type scannerScaleStats struct {
|
||||
Calls, Rows, WALRecords, WALFpi, WALBytes, SharedBlksHit, SharedBlksRead, SharedBlksDirtied int64
|
||||
TotalExecTime float64
|
||||
StatsReset string
|
||||
Dealloc int64
|
||||
}
|
||||
|
||||
func captureScannerScaleStats(t *testing.T) scannerScaleStats {
|
||||
t.Helper()
|
||||
var result scannerScaleStats
|
||||
if config.GetDBKind() != "postgres" {
|
||||
return result
|
||||
}
|
||||
db := database.GetDB()
|
||||
if err := db.Raw(`SELECT COALESCE(sum(calls),0) AS calls, COALESCE(sum(rows),0) AS rows,
|
||||
COALESCE(sum(total_exec_time),0) AS total_exec_time,
|
||||
COALESCE(sum(wal_records),0) AS wal_records, COALESCE(sum(wal_fpi),0) AS wal_fpi, COALESCE(sum(wal_bytes),0) AS wal_bytes,
|
||||
COALESCE(sum(shared_blks_hit),0) AS shared_blks_hit, COALESCE(sum(shared_blks_read),0) AS shared_blks_read,
|
||||
COALESCE(sum(shared_blks_dirtied),0) AS shared_blks_dirtied
|
||||
FROM public.pg_stat_statements
|
||||
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||
AND (query ILIKE '%inbound_client_ips%' OR query ILIKE '%node_client_ips%')
|
||||
AND query NOT ILIKE '%pg_stat_statements%'`).Scan(&result).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var info struct {
|
||||
StatsReset string
|
||||
Dealloc int64
|
||||
}
|
||||
if err := db.Raw("SELECT stats_reset::text AS stats_reset, dealloc FROM public.pg_stat_statements_info").Scan(&info).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result.StatsReset, result.Dealloc = info.StatsReset, info.Dealloc
|
||||
return result
|
||||
}
|
||||
|
||||
func logScannerScaleStats(t *testing.T, before, after scannerScaleStats) {
|
||||
t.Helper()
|
||||
if config.GetDBKind() != "postgres" {
|
||||
return
|
||||
}
|
||||
t.Logf("PG own-db IP statements delta calls=%d rows=%d total_exec_time_ms=%.3f wal_records=%d wal_fpi=%d wal_bytes=%d shared_blks_hit=%d shared_blks_read=%d shared_blks_dirtied=%d stats_reset=%q->%q dealloc=%d->%d",
|
||||
after.Calls-before.Calls, after.Rows-before.Rows, after.TotalExecTime-before.TotalExecTime,
|
||||
after.WALRecords-before.WALRecords, after.WALFpi-before.WALFpi, after.WALBytes-before.WALBytes,
|
||||
after.SharedBlksHit-before.SharedBlksHit, after.SharedBlksRead-before.SharedBlksRead, after.SharedBlksDirtied-before.SharedBlksDirtied,
|
||||
before.StatsReset, after.StatsReset, before.Dealloc, after.Dealloc)
|
||||
}
|
||||
|
||||
type scannerScaleCounts [9]atomic.Int64
|
||||
|
||||
func (c *scannerScaleCounts) snapshot() [9]int64 {
|
||||
var out [9]int64
|
||||
for i := range out {
|
||||
out[i] = c[i].Load()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countScannerScaleSQL(t *testing.T) *scannerScaleCounts {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
counts := &scannerScaleCounts{}
|
||||
const name = "test:scanner_scale_sql"
|
||||
if err := db.Callback().Query().After("gorm:query").Register(name, func(tx *gorm.DB) {
|
||||
switch tx.Statement.Table {
|
||||
case "inbound_client_ips":
|
||||
counts[0].Add(1)
|
||||
if strings.Contains(tx.Statement.SQL.String(), "WHERE id =") {
|
||||
counts[1].Add(1)
|
||||
}
|
||||
case "node_client_ips":
|
||||
counts[6].Add(1)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Update().After("gorm:update").Register(name, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "inbound_client_ips" {
|
||||
counts[2].Add(1)
|
||||
counts[3].Add(tx.RowsAffected)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Create().After("gorm:create").Register(name, func(tx *gorm.DB) {
|
||||
switch tx.Statement.Table {
|
||||
case "inbound_client_ips":
|
||||
counts[4].Add(1)
|
||||
counts[5].Add(tx.RowsAffected)
|
||||
case "node_client_ips":
|
||||
counts[7].Add(1)
|
||||
counts[8].Add(tx.RowsAffected)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Callback().Query().Remove(name)
|
||||
_ = db.Callback().Update().Remove(name)
|
||||
_ = db.Callback().Create().Remove(name)
|
||||
})
|
||||
return counts
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func TestClientIpWritersPreserveCanonicalBytes(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
clientIpWritersPreserveCanonicalBytes(t)
|
||||
}
|
||||
|
||||
func clientIpWritersPreserveCanonicalBytes(t *testing.T) {
|
||||
t.Helper()
|
||||
const email = "canonical-writers@x"
|
||||
seedLinkedInboundWithClient(t, "canonical-writers", email, 3)
|
||||
now := time.Now().Unix()
|
||||
entries := []model.ClientIpEntry{
|
||||
{IP: "192.0.2.9", Timestamp: now},
|
||||
{IP: "10.0.0.1", Timestamp: now},
|
||||
{IP: "203.0.113.8", Timestamp: now - 300},
|
||||
{IP: "192.0.2.1", Timestamp: now},
|
||||
}
|
||||
want := fmt.Sprintf(`[{"ip":"203.0.113.8","timestamp":%d},{"ip":"10.0.0.1","timestamp":%d},{"ip":"192.0.2.1","timestamp":%d},{"ip":"192.0.2.9","timestamp":%d}]`, now-300, now, now, now)
|
||||
row := model.InboundClientIps{ClientEmail: email, Ips: want}
|
||||
db := database.GetDB()
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flatWrites, attributionWrites := 0, 0
|
||||
const callback = "test:canonical_writer_updates"
|
||||
if err := db.Callback().Update().After("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema == nil || tx.RowsAffected == 0 {
|
||||
return
|
||||
}
|
||||
if tx.Statement.Schema.Table == "inbound_client_ips" {
|
||||
flatWrites++
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Update().Remove(callback) })
|
||||
if err := db.Callback().Create().After("gorm:create").Register(callback, func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Table == "node_client_ips" && tx.RowsAffected > 0 {
|
||||
attributionWrites++
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
|
||||
j := NewCheckClientIpJob()
|
||||
j.allowlist = parseIpLimitAllowlist("10.0.0.1")
|
||||
observed := map[string]map[string]int64{email: {"192.0.2.9": now, "10.0.0.1": now, "192.0.2.1": now}}
|
||||
for iteration := range 4 {
|
||||
blob, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&service.InboundService{}).MergeInboundClientIps([]model.InboundClientIps{{ClientEmail: email, Ips: string(blob)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j.processObserved(observed, true, false)
|
||||
var got model.InboundClientIps
|
||||
if err := db.Where("client_email = ?", email).First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Ips != want {
|
||||
t.Fatalf("iteration %d canonical bytes = %s, want %s", iteration, got.Ips, want)
|
||||
}
|
||||
}
|
||||
if flatWrites != 0 {
|
||||
t.Fatalf("unchanged alternating writers performed %d flat updates, want 0", flatWrites)
|
||||
}
|
||||
beforeAttribution := attributionWrites
|
||||
j.processObserved(map[string]map[string]int64{email: {"192.0.2.9": now + 1}}, true, false)
|
||||
if flatWrites != 1 {
|
||||
t.Fatalf("advancing observation flat updates = %d, want 1", flatWrites)
|
||||
}
|
||||
advancedWant := fmt.Sprintf(`[{"ip":"203.0.113.8","timestamp":%d},{"ip":"10.0.0.1","timestamp":%d},{"ip":"192.0.2.1","timestamp":%d},{"ip":"192.0.2.9","timestamp":%d}]`, now-300, now, now, now+1)
|
||||
var advanced model.InboundClientIps
|
||||
if err := db.Where("client_email = ?", email).First(&advanced).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if advanced.Ips != advancedWant {
|
||||
t.Fatalf("advancing flat bytes = %s, want %s", advanced.Ips, advancedWant)
|
||||
}
|
||||
if attributionWrites != beforeAttribution+1 {
|
||||
t.Fatalf("advancing attribution writes = %d, want %d", attributionWrites, beforeAttribution+1)
|
||||
}
|
||||
var attribution model.NodeClientIp
|
||||
if err := db.Where("email = ?", email).First(&attribution).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var attributed []model.ClientIpEntry
|
||||
if err := json.Unmarshal([]byte(attribution.Ips), &attributed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, entry := range attributed {
|
||||
if entry.IP == "192.0.2.9" {
|
||||
found = entry.Timestamp == now+1
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("advancing attribution = %v, want 192.0.2.9 at %d", attributed, now+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessObserved_CreateConflictPreservesUnion(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
processObservedCreateConflictPreservesUnion(t)
|
||||
}
|
||||
|
||||
func processObservedCreateConflictPreservesUnion(t *testing.T) {
|
||||
t.Helper()
|
||||
const email = "create-conflict@x"
|
||||
seedLinkedInboundWithClient(t, "create-conflict", email, 3)
|
||||
now := time.Now().Unix()
|
||||
winning := fmt.Sprintf(`[{"ip":"192.0.2.1","timestamp":%d},{"ip":"203.0.113.8","timestamp":%d}]`, now-30, now)
|
||||
db := database.GetDB()
|
||||
injected := false
|
||||
const callback = "test:scanner_create_conflict"
|
||||
if err := db.Callback().Create().Before("gorm:create").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{NewDB: true, SkipHooks: true}).Exec("INSERT INTO inbound_client_ips (client_email, ips) VALUES (?, ?)", email, winning).Error; err != nil {
|
||||
_ = tx.AddError(err)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
|
||||
NewCheckClientIpJob().processObserved(map[string]map[string]int64{email: {"192.0.2.1": now, "198.51.100.2": now - 5}}, true, true)
|
||||
if !injected {
|
||||
t.Fatal("missing-row insert was not exercised")
|
||||
}
|
||||
var row model.InboundClientIps
|
||||
if err := db.Where("client_email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := fmt.Sprintf(`[{"ip":"198.51.100.2","timestamp":%d},{"ip":"192.0.2.1","timestamp":%d},{"ip":"203.0.113.8","timestamp":%d}]`, now-5, now, now)
|
||||
if row.Ips != want {
|
||||
t.Fatalf("conflict union bytes = %s, want %s", row.Ips, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIpSyncPostgresScanner(t *testing.T) {
|
||||
if os.Getenv("XUI_DB_TYPE") != "postgres" || os.Getenv("XUI_DB_DSN") == "" {
|
||||
t.Skip("requires PostgreSQL")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
run func(*testing.T)
|
||||
}{
|
||||
{"CanonicalWriters", clientIpWritersPreserveCanonicalBytes},
|
||||
{"CreateConflict", processObservedCreateConflictPreservesUnion},
|
||||
{"ConcurrentWriter", processObservedKeepsNodeSyncWrite},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cleanup, err := testpg.IsolatePackage("job_ip_sync")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
||||
loggerInitOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc.run(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// An address that matches is neither counted towards a client's IP limit nor
|
||||
@@ -66,11 +68,11 @@ func (l ipLimitAllowlist) contains(ip string) bool {
|
||||
|
||||
// split separates the entries an allowlist protects from the ones the limit
|
||||
// still applies to, preserving the caller's ordering in both.
|
||||
func (l ipLimitAllowlist) split(entries []IPWithTimestamp) (limited, allowed []IPWithTimestamp) {
|
||||
func (l ipLimitAllowlist) split(entries []model.ClientIpEntry) (limited, allowed []model.ClientIpEntry) {
|
||||
if l.empty() {
|
||||
return entries, nil
|
||||
}
|
||||
limited = make([]IPWithTimestamp, 0, len(entries))
|
||||
limited = make([]model.ClientIpEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if l.contains(entry.IP) {
|
||||
allowed = append(allowed, entry)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package job
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Addresses in the examples below come from the documentation ranges reserved
|
||||
// by RFC 5737 and RFC 3849.
|
||||
@@ -37,7 +41,7 @@ func TestIpLimitAllowlistIgnoresUnparsableEntries(t *testing.T) {
|
||||
// The point of the setting: a shared address is neither banned nor counted, so
|
||||
// the office NAT it protects does not consume the client's limit either.
|
||||
func TestIpLimitAllowlistSplitKeepsAllowedOutOfTheCount(t *testing.T) {
|
||||
live := []IPWithTimestamp{
|
||||
live := []model.ClientIpEntry{
|
||||
{IP: "203.0.113.10", Timestamp: 1},
|
||||
{IP: "192.0.2.1", Timestamp: 2},
|
||||
{IP: "192.0.2.2", Timestamp: 3},
|
||||
@@ -62,7 +66,7 @@ func TestIpLimitAllowlistSplitKeepsAllowedOutOfTheCount(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIpLimitAllowlistEmptyListChangesNothing(t *testing.T) {
|
||||
live := []IPWithTimestamp{{IP: "192.0.2.1", Timestamp: 1}, {IP: "192.0.2.2", Timestamp: 2}}
|
||||
live := []model.ClientIpEntry{{IP: "192.0.2.1", Timestamp: 1}, {IP: "192.0.2.2", Timestamp: 2}}
|
||||
limited, allowed := parseIpLimitAllowlist("").split(live)
|
||||
if allowed != nil || len(limited) != 2 {
|
||||
t.Fatalf("empty allowlist changed the input: limited=%v allowed=%v", limited, allowed)
|
||||
|
||||
Reference in New Issue
Block a user