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