mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-04-25 08:55:59 +00:00
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
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:
commit
e6d0c33937
2 changed files with 124 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
77
web/job/check_client_ip_job_test.go
Normal file
77
web/job/check_client_ip_job_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue