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