mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 20:07:13 +00:00
feat: synchronize access.log client IPs across nodes (#5098)
* feat: synchronize access.log client IPs across nodes for global fail2ban limits * fix(nodes): harden cross-node client-IP merge for cluster fail2ban MergeInboundClientIps inserted new rows with the remote node's primary key, which collides with the independently auto-incremented local id and rolled back the whole sync batch — breaking exactly the node-only clients the feature targets. It also never evicted stale IPs, so the 30-minute cutoff was defeated cluster-wide (the master pushed its unpruned table back to nodes, which re-added IPs they had just pruned) and the blobs grew unbounded. - drop the remote id on create (Id=0) and guard the email-unique race with ON CONFLICT DO NOTHING; also fixes a latent Postgres sequence collision - apply the same 30-minute stale cutoff inside the merge and skip creating node-only rows whose IPs are all stale - throttle the IP fetch/merge/push to ~10s (data only refreshes every 10s) instead of running on every 5s traffic tick, cutting SQLite write churn - log the load error on the push path and tidy the merge response message - add unit tests for the merge (remote-id, dedup, stale-drop, skips) --------- Co-authored-by: Rqzbeh <Rqzbeh@example.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -242,9 +242,13 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
|
||||
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
||||
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
||||
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
||||
now := time.Now().Unix()
|
||||
for ip, ts := range ipMap {
|
||||
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
|
||||
if observedThisScan[ip] {
|
||||
// Consider an IP "live" if it was seen locally in this scan, OR if its
|
||||
// timestamp from the synced database is very recent (e.g. within 2 minutes).
|
||||
// This ensures cluster-wide limits work even if the IP was seen on another node.
|
||||
if observedThisScan[ip] || now-ts < 120 {
|
||||
live = append(live, entry)
|
||||
} else {
|
||||
historical = append(historical, entry)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
||||
@@ -149,6 +150,26 @@ func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionLiveIps_RecentSyncedIpIsLive(t *testing.T) {
|
||||
// Synced IPs from other nodes within 2 minutes should be counted as live
|
||||
// even if they weren't observed in the local scan.
|
||||
now := time.Now().Unix()
|
||||
ipMap := map[string]int64{
|
||||
"A": now - 30, // synced 30s ago -> live
|
||||
"B": now - 150, // synced 2m30s ago -> historical
|
||||
}
|
||||
observed := map[string]bool{}
|
||||
|
||||
live, historical := partitionLiveIps(ipMap, observed)
|
||||
|
||||
if got := collectIps(live); !reflect.DeepEqual(got, []string{"A"}) {
|
||||
t.Fatalf("recent IP should be live\ngot: %v\nwant: [A]", got)
|
||||
}
|
||||
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"B"}) {
|
||||
t.Fatalf("older IP should be historical\ngot: %v\nwant: [B]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
|
||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
|
||||
marker := fakeFail2BanClient(t)
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
nodeTrafficSyncConcurrency = 8
|
||||
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
||||
nodeReconcileTimeout = 30 * time.Second
|
||||
nodeClientIpSyncInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
type NodeTrafficSyncJob struct {
|
||||
@@ -25,6 +26,8 @@ type NodeTrafficSyncJob struct {
|
||||
xrayService service.XrayService
|
||||
running sync.Mutex
|
||||
structural atomicBool
|
||||
ipSyncMu sync.Mutex
|
||||
lastIpSync int64
|
||||
}
|
||||
|
||||
type atomicBool struct {
|
||||
@@ -70,6 +73,16 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
// Decide once per tick whether this run also syncs client IPs, and stamp the
|
||||
// clock before the loop so two back-to-back 5s ticks can't both qualify.
|
||||
doIpSync := false
|
||||
j.ipSyncMu.Lock()
|
||||
if now := time.Now().Unix(); now-j.lastIpSync >= int64(nodeClientIpSyncInterval/time.Second) {
|
||||
doIpSync = true
|
||||
j.lastIpSync = now
|
||||
}
|
||||
j.ipSyncMu.Unlock()
|
||||
|
||||
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, n := range nodes {
|
||||
@@ -81,7 +94,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
go func(n *model.Node) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
j.syncOne(mgr, n)
|
||||
j.syncOne(mgr, n, doIpSync)
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -151,7 +164,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) {
|
||||
rt, err := mgr.RemoteFor(n)
|
||||
if err != nil {
|
||||
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
|
||||
@@ -190,4 +203,28 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
||||
if changed {
|
||||
j.structural.set()
|
||||
}
|
||||
|
||||
if !doIpSync {
|
||||
return
|
||||
}
|
||||
|
||||
nodeIps, err := rt.FetchAllClientIps(ctx)
|
||||
if err == nil && len(nodeIps) > 0 {
|
||||
if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil {
|
||||
logger.Warning("node traffic sync: merge client ips from", n.Name, "failed:", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
logger.Warning("node traffic sync: fetch client ips from", n.Name, "failed:", err)
|
||||
}
|
||||
|
||||
masterIps, err := j.inboundService.GetAllInboundClientIps()
|
||||
if err != nil {
|
||||
logger.Warning("node traffic sync: load client ips for push to", n.Name, "failed:", err)
|
||||
return
|
||||
}
|
||||
if len(masterIps) > 0 {
|
||||
if err := rt.PushAllClientIps(ctx, masterIps); err != nil {
|
||||
logger.Warning("node traffic sync: push client ips to", n.Name, "failed:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user