Files
3x-ui/internal/web/job/check_client_ip_frozen_ban_test.go
T
MHSanaei 975b1f1acc 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
2026-07-11 22:48:58 +02:00

69 lines
1.9 KiB
Go

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)
}
}