From 975b1f1accfbed4aae5055163cd9ce5e86dc093a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 11 Jul 2026 21:52:35 +0200 Subject: [PATCH] fix(iplimit): ban a dead connection once instead of every scan When a client's connection drops without a clean TCP close, xray-core keeps its online-map entry until the session context ends (idle policy), minutes after the kernel socket is gone. The 10s IP-limit scan kept seeing that stale IP as the oldest live one and re-emitted the same [LIMIT_IP] Disconnecting OLD IP line plus a RemoveUser/AddUser cycle every scan - operators measured 100+ repeats over ~1000s for a single network switch, forcing absurd fail2ban maxretry values to avoid banning legitimate mobile users. The core refreshes an entry's lastSeen only when a new connection from that IP is dispatched, never on traffic, so a frozen lastSeen across scans is a dead connection, not a reconnect. Track the lastSeen of each banned (email, ip) pair and skip the log line and disconnect until it advances; a real reconnect moves lastSeen and is enforced exactly as before, and an age cutoff that could misclassify long-lived active tunnels is deliberately avoided. Closes #5893 --- .../job/check_client_ip_frozen_ban_test.go | 68 +++++++++++++++++++ internal/web/job/check_client_ip_job.go | 36 +++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 internal/web/job/check_client_ip_frozen_ban_test.go diff --git a/internal/web/job/check_client_ip_frozen_ban_test.go b/internal/web/job/check_client_ip_frozen_ban_test.go new file mode 100644 index 000000000..fd25fc7ee --- /dev/null +++ b/internal/web/job/check_client_ip_frozen_ban_test.go @@ -0,0 +1,68 @@ +package job + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" +) + +func banLineCount(t *testing.T, email string) int { + t.Helper() + body, err := os.ReadFile(readIpLimitLogPath()) + if err != nil { + if os.IsNotExist(err) { + return 0 + } + t.Fatalf("read 3xipl.log: %v", err) + } + return strings.Count(string(body), "[LIMIT_IP] Email = "+email) +} + +func TestUpdateInboundClientIps_FrozenLastSeenBannedOnce(t *testing.T) { + setupIntegrationDB(t) + + const email = "frozen-ban" + seedInboundWithClient(t, "inbound-frozen-ban", email, 1) + + now := time.Now().Unix() + deadStart := now - 300 + live := []IPWithTimestamp{ + {IP: "10.2.0.1", Timestamp: deadStart}, + {IP: "192.0.2.7", Timestamp: now}, + } + + j := NewCheckClientIpJob() + inbound, err := j.getInboundByEmail(email) + if err != nil { + t.Fatalf("getInboundByEmail: %v", err) + } + row := seedClientIps(t, email, nil) + + if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, true); !banned { + t.Fatalf("first scan: the over-limit stale IP must be banned") + } + if got := banLineCount(t, email); got != 1 { + t.Fatalf("ban lines after first scan = %d, want 1", got) + } + + if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, true); banned { + t.Fatalf("second scan with a frozen lastSeen must not re-ban a dead connection") + } + if got := banLineCount(t, email); got != 1 { + t.Fatalf("ban lines after frozen rescan = %d, want still 1", got) + } + + reconnected := []IPWithTimestamp{ + {IP: "10.2.0.1", Timestamp: now + 30}, + {IP: "192.0.2.7", Timestamp: now + 60}, + } + if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, reconnected, true, true); !banned { + t.Fatalf("a reconnect (advanced lastSeen) must be banned again") + } + if got := banLineCount(t, email); got != 2 { + t.Fatalf("ban lines after reconnect = %d, want 2", got) + } +} diff --git a/internal/web/job/check_client_ip_job.go b/internal/web/job/check_client_ip_job.go index fb7ad89f0..52c93192b 100644 --- a/internal/web/job/check_client_ip_job.go +++ b/internal/web/job/check_client_ip_job.go @@ -9,6 +9,7 @@ import ( "os/exec" "runtime" "sort" + "strings" "time" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -32,6 +33,7 @@ type IPWithTimestamp struct { // simply skips the run (the bundled core always supports it). type CheckClientIpJob struct { disAllowedIps []string + bannedSeen map[string]int64 xrayService service.XrayService } @@ -509,7 +511,8 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps // historical db-only ips are excluded from this count on purpose. keptLive, bannedLive := selectIpsToBan(liveIps, limitIp) - if len(bannedLive) > 0 { + actionable := j.filterAdvancedSinceLastBan(clientEmail, bannedLive) + if len(actionable) > 0 { shouldCleanLog = true banned = true @@ -525,7 +528,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps // filter.d/3x-ipl.conf with // failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+ // don't change the wording. - for _, ipTime := range bannedLive { + for _, ipTime := range actionable { j.disAllowedIps = append(j.disAllowedIps, ipTime.IP) ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp) } @@ -552,6 +555,35 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps return shouldCleanLog, banned } +// filterAdvancedSinceLastBan keeps only banned pairs whose lastSeen advanced since +// the previous ban: the core refreshes lastSeen solely on a new dispatch, so a +// frozen value is a dead connection it hasn't reaped yet, not a reconnect. +func (j *CheckClientIpJob) filterAdvancedSinceLastBan(email string, banned []IPWithTimestamp) []IPWithTimestamp { + if j.bannedSeen == nil { + j.bannedSeen = make(map[string]int64) + } + current := make(map[string]struct{}, len(banned)) + actionable := make([]IPWithTimestamp, 0, len(banned)) + for _, ipTime := range banned { + key := email + "|" + ipTime.IP + current[key] = struct{}{} + if last, ok := j.bannedSeen[key]; ok && ipTime.Timestamp <= last { + continue + } + j.bannedSeen[key] = ipTime.Timestamp + actionable = append(actionable, ipTime) + } + prefix := email + "|" + for key := range j.bannedSeen { + if strings.HasPrefix(key, prefix) { + if _, still := current[key]; !still { + delete(j.bannedSeen, key) + } + } + } + return actionable +} + // disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) { var xrayAPI xray.XrayAPI