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:
Rouzbeh†
2026-06-09 00:59:50 +02:00
committed by GitHub
parent 0d7b6872f7
commit 9f31d7d056
9 changed files with 532 additions and 3 deletions
+5 -1
View File
@@ -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)
+21
View File
@@ -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)
+39 -2
View File
@@ -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)
}
}
}