mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-12 07:36:07 +00:00
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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user