浏览代码

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
MHSanaei 1 天之前
父节点
当前提交
975b1f1acc
共有 2 个文件被更改,包括 102 次插入2 次删除
  1. 68 0
      internal/web/job/check_client_ip_frozen_ban_test.go
  2. 34 2
      internal/web/job/check_client_ip_job.go

+ 68 - 0
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)
+	}
+}

+ 34 - 2
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*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\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