From b32837e523421fa6bf6158be518863d38f65f95a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 25 Jun 2026 21:19:27 +0200 Subject: [PATCH] fix(node): import per-client traffic history on first sync of a node-hosted inbound On the first sync of a node-hosted inbound, the central inbound adopted the node's full lifetime counter but every client_traffics row was seeded at 0 (with the delta baseline set to the node's current counter). So adding or migrating a node that already had traffic kept the inbound total correct while every per-client counter restarted from zero, and the master under-reported per-client usage by the entire pre-attach history. Seed a new client_traffics row from the node counter only when the inbound was created during the same sync (a genuine node-add / inbound re-import); a client reappearing under a pre-existing inbound still seeds 0, preserving the ghost protection in TestGhostData_NoPhantomTraffic. The seed is additionally gated on the delete tombstone so a just-deleted client cannot be resurrected if its inbound is recreated. Baseline still equals the seeded value, so the next sync delta is 0 and no traffic is double counted. Adds TestNodeAdd_ImportsClientHistoryWithNewInbound and TestNodeAdd_TombstonedClientNotResurrected. --- internal/web/service/inbound_node.go | 11 ++++++-- .../service/node_client_traffic_sum_test.go | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index c7782f962..e83ba9376 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -380,6 +380,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi structuralChange := false + newInboundIDs := make(map[int]struct{}) + snapTags := make(map[string]struct{}, len(snap.Inbounds)) for _, snapIb := range snap.Inbounds { if snapIb == nil { @@ -466,6 +468,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if newIb.Tag != snapIb.Tag { tagToCentral[newIb.Tag] = &newIb } + newInboundIDs[newIb.Id] = struct{}{} structuralChange = true continue } @@ -620,6 +623,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if dirty { continue } + var seedUp, seedDown int64 + if _, isNewInbound := newInboundIDs[c.Id]; isNewInbound && !isClientEmailTombstoned(cs.Email) { + seedUp, seedDown = canon.Up, canon.Down + } row := &xray.ClientTraffic{ InboundId: c.Id, Email: cs.Email, @@ -627,8 +634,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi Total: cs.Total, ExpiryTime: cs.ExpiryTime, Reset: cs.Reset, - Up: 0, - Down: 0, + Up: seedUp, + Down: seedDown, LastOnline: cs.LastOnline, } if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}). diff --git a/internal/web/service/node_client_traffic_sum_test.go b/internal/web/service/node_client_traffic_sum_test.go index 0676a1fcf..c8b60f6a8 100644 --- a/internal/web/service/node_client_traffic_sum_test.go +++ b/internal/web/service/node_client_traffic_sum_test.go @@ -120,6 +120,32 @@ func TestSingleNode_MirrorsCorrectly(t *testing.T) { assertUpDown(t, readTraffic(t, db, email), 200, 200, "second sync — delta accrues") } +func TestNodeAdd_ImportsClientHistoryWithNewInbound(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + + const email = "newnode-client" + const histUp, histDown int64 = 6_000_000_000, 200_000_000_000 + + syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: histUp, Down: histDown, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), histUp, histDown, "node-add: client history imported with its brand-new inbound") + + syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: histUp + 1024, Down: histDown + 2048, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), histUp+1024, histDown+2048, "post-import delta accrues, no double count") +} + +func TestNodeAdd_TombstonedClientNotResurrected(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + + const email = "deleted-ghost" + const stale int64 = 50_000_000_000 + + tombstoneClientEmail(email) + syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: stale, Down: stale, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), 0, 0, "tombstoned client must not resurrect via node-add seed") +} + func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) { db := initTrafficTestDB(t) createNodeInbound(t, db, 1, "n1-in", 41001)