Merge pull request #4083 from pwnnex/fix/iplimit-stale-db-evict
Some checks are pending
Release 3X-UI / Analyze Go code (push) Waiting to run
Release 3X-UI / build (386) (push) Blocked by required conditions
Release 3X-UI / build (amd64) (push) Blocked by required conditions
Release 3X-UI / build (arm64) (push) Blocked by required conditions
Release 3X-UI / build (armv5) (push) Blocked by required conditions
Release 3X-UI / build (armv6) (push) Blocked by required conditions
Release 3X-UI / build (armv7) (push) Blocked by required conditions
Release 3X-UI / build (s390x) (push) Blocked by required conditions
Release 3X-UI / Build for Windows (push) Blocked by required conditions

Fix IP Limit continuous ban loop after a legitimate ban expires (#4077)
This commit is contained in:
pwnnex 2026-04-22 14:09:55 +00:00 committed by GitHub
commit e6d0c33937
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 124 additions and 10 deletions

View file

@ -35,6 +35,25 @@ var job *CheckClientIpJob
const defaultXrayAPIPort = 62789
// ipStaleAfterSeconds controls how long a client IP kept in the
// per-client tracking table (model.InboundClientIps.Ips) is considered
// still "active" before it's evicted during the next scan.
//
// Without this eviction, an IP that connected once and then went away
// keeps sitting in the table with its old timestamp. Because the
// excess-IP selector sorts ascending ("oldest wins, newest loses") to
// protect the original/current connections, that stale entry keeps
// occupying a slot and the IP that is *actually* currently using the
// config gets classified as "new excess" and banned by fail2ban on
// every single run — producing the continuous ban loop from #4077.
//
// 30 minutes is chosen so an actively-streaming client (where xray
// emits a fresh `accepted` log line whenever it opens a new TCP) will
// always refresh its timestamp well within the window, but a client
// that has really stopped using the config will drop out of the table
// in a bounded time and free its slot.
const ipStaleAfterSeconds = int64(30 * 60)
// NewCheckClientIpJob creates a new client IP monitoring job instance.
func NewCheckClientIpJob() *CheckClientIpJob {
job = new(CheckClientIpJob)
@ -202,6 +221,31 @@ func (j *CheckClientIpJob) processLogFile() bool {
return shouldCleanLog
}
// mergeClientIps combines the persisted (old) and freshly observed (new)
// IP-with-timestamp lists for a single client into a map. An entry is
// dropped if its last-seen timestamp is older than staleCutoff.
//
// Extracted as a helper so updateInboundClientIps can stay DB-oriented
// and the merge policy can be exercised by a unit test.
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
ipMap := make(map[string]int64, len(old)+len(new))
for _, ipTime := range old {
if ipTime.Timestamp < staleCutoff {
continue
}
ipMap[ipTime.IP] = ipTime.Timestamp
}
for _, ipTime := range new {
if ipTime.Timestamp < staleCutoff {
continue
}
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
ipMap[ipTime.IP] = ipTime.Timestamp
}
}
return ipMap
}
func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
cmd := "fail2ban-client"
args := []string{"-h"}
@ -310,16 +354,9 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
}
// Merge old and new IPs, keeping the latest timestamp for each IP
ipMap := make(map[string]int64)
for _, ipTime := range oldIpsWithTime {
ipMap[ipTime.IP] = ipTime.Timestamp
}
for _, ipTime := range newIpsWithTime {
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
ipMap[ipTime.IP] = ipTime.Timestamp
}
}
// Merge old and new IPs, evicting entries that haven't been
// re-observed in a while. See mergeClientIps / #4077 for why.
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
// Convert back to slice and sort by timestamp (oldest first)
// This ensures we always protect the original/current connections and ban new excess ones.

View file

@ -0,0 +1,77 @@
package job
import (
"reflect"
"testing"
)
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
// #4077: after a ban expires, a single IP that reconnects used to get
// banned again immediately because a long-disconnected IP stayed in the
// DB with an ancient timestamp and kept "protecting" itself against
// eviction. Guard against that regression here.
old := []IPWithTimestamp{
{IP: "1.1.1.1", Timestamp: 100}, // stale — client disconnected long ago
{IP: "2.2.2.2", Timestamp: 1900}, // fresh — still connecting
}
new := []IPWithTimestamp{
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
}
got := mergeClientIps(old, new, 1000)
want := map[string]int64{"2.2.2.2": 2000}
if !reflect.DeepEqual(got, want) {
t.Fatalf("stale 1.1.1.1 should have been dropped\ngot: %v\nwant: %v", got, want)
}
}
func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
// Backwards-compat: entries that aren't stale are still carried forward,
// so enforcement survives access-log rotation.
old := []IPWithTimestamp{
{IP: "1.1.1.1", Timestamp: 1500},
}
got := mergeClientIps(old, nil, 1000)
want := map[string]int64{"1.1.1.1": 1500}
if !reflect.DeepEqual(got, want) {
t.Fatalf("fresh old IP should have been retained\ngot: %v\nwant: %v", got, want)
}
}
func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
got := mergeClientIps(old, new, 1000)
if got["1.1.1.1"] != 1700 {
t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
}
}
func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
// A log line with a clock-skewed old timestamp must not resurrect a
// stale IP past the cutoff.
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
got := mergeClientIps(nil, new, 1000)
if len(got) != 0 {
t.Fatalf("stale new IP should have been dropped, got %v", got)
}
}
func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
// Defensive: a zero cutoff (e.g. during very first run on a fresh
// install) must not over-evict.
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
got := mergeClientIps(old, new, 0)
want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
if !reflect.DeepEqual(got, want) {
t.Fatalf("zero cutoff should keep everything\ngot: %v\nwant: %v", got, want)
}
}