From 0394996a3039407f3bfc6a1b0710aff141431f46 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 10:25:14 +0100 Subject: [PATCH] fix(node): keep summing usage across panels for a client the node shares by email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client gate refused a node's entry for an email the master serves only on its own or another node's inbounds, and so also dropped the node's usage for it; on top of that, a refused or rewritten entry made the master mark the node dirty and push its own settings back, which deleted the node's local client of that email. A master → node → node chain whose panels each serve the same client therefore stopped adding the nodes' usage to the master's totals. Such an email is now usage-only: the node's usage still adds to the master's row, while its enable, expiry and record values do not. A claim rewrite no longer forces a re-push; the stored, filtered settings are what the reconcile fingerprint tracks. --- internal/web/service/inbound_node.go | 43 ++++++++----------- internal/web/service/inbound_node_claims.go | 26 +++++++++++ .../web/service/node_foreign_client_test.go | 30 ++++++------- 3 files changed, 59 insertions(+), 40 deletions(-) diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index ba846a053..378295410 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "sort" "strings" "sync" @@ -676,8 +677,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if err != nil { return false, err } - // The raw blob stays for the reconcile fingerprint, so a rewrite leaves a mismatch to - // re-push the master's values against. + // The raw blob stays for the usage-only lookup: those entries are refused from the stored + // settings, yet the node's usage under them still counts. rawSettings := make(map[*model.Inbound]string, len(snap.Inbounds)) for _, snapIb := range snap.Inbounds { if snapIb != nil { @@ -688,7 +689,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi structuralChange := false lifecycleLifted := false - claimsRewritten := false var sweptTags []string var adoptedInbounds []*model.Inbound @@ -696,7 +696,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi central *model.Inbound snapIb *model.Inbound wireSettings string - fingerprint string } var pendingAdopts []pendingAdopt degradedInbounds := map[int]string{} @@ -864,11 +863,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if deduped, changed := dedupeSettingsClients(adoptedSettings); changed { adoptedSettings = deduped } - fingerprint := adoptedSettings - if raw := rawSettings[snapIb]; raw != snapIb.Settings { - fingerprint, _ = stripTombstonedClients(raw) - fingerprint, _ = dedupeSettingsClients(fingerprint) - } updates := map[string]any{} if !dirty && snapshotDropsEveryHubClient(tx, c.Id, adoptedSettings) { degradedInbounds[c.Id] = c.Tag @@ -876,7 +870,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // Defer lifecycle lift until after client_traffics absorbs this tick's // deltas so quota stale-disable matches SQL (#6228). pendingAdopts = append(pendingAdopts, pendingAdopt{ - central: c, snapIb: snapIb, wireSettings: adoptedSettings, fingerprint: fingerprint, + central: c, snapIb: snapIb, wireSettings: adoptedSettings, }) updates["enable"] = snapIb.Enable updates["remark"] = snapIb.Remark @@ -1058,6 +1052,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // Parsed once per inbound on the first renewal candidate, not per client. var snapExpiries map[string]int64 attachedOnNode, membershipKnown := snapshotAttachedEmails(snapIb.Settings) + usageOnly := claims.usageOnlyIn(rawSettings[snapIb]) + if membershipKnown { + maps.Copy(attachedOnNode, usageOnly) + } for _, cs := range snapIb.ClientStats { // A node detach keeps the stat row, so its quota and verdict are stale (#6724). if _, attached := attachedOnNode[strings.ToLower(cs.Email)]; membershipKnown && !attached { @@ -1070,7 +1068,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // Until the node applies a reset it owes, its verdict rests on the // pre-reset counters: only usage may move for this client. _, owed := owedResets[cs.Email] - clientFrozen := lifecycleFrozen || owed + _, onlyUsage := usageOnly[strings.ToLower(cs.Email)] + clientFrozen := lifecycleFrozen || owed || onlyUsage base, seen := nodeBaselines[cs.Email] var deltaUp, deltaDown int64 @@ -1290,12 +1289,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // so the adopted rows are served this tick, not after the TTL. InvalidateSubData() } - rewritten := p.fingerprint != p.wireSettings - claimsRewritten = claimsRewritten || rewritten - // The fingerprint stamps the node's own blob on purpose: a lift or a refused claim - // must leave reconcile a mismatch to re-push against. - if liftChanged || rewritten || adoptedWireChanged(p.central, p.snapIb, p.fingerprint) { - adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(p.central, p.snapIb, p.fingerprint)) + // The fingerprint stamps the un-lifted wire blob on purpose: a lift must leave reconcile + // a mismatch to re-push against. A refused claim must not: the push would delete it on the node. + if liftChanged || adoptedWireChanged(p.central, p.snapIb, p.wireSettings) { + adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(p.central, p.snapIb, p.wireSettings)) } } for _, snapIb := range snap.Inbounds { @@ -1454,18 +1451,14 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } - if (lifecycleLifted || claimsRewritten) && !dirty { - reason := "lifecycle lift" - if !lifecycleLifted { - reason = "client claim rewrite" - } + if lifecycleLifted && !dirty { var already model.Node if err := database.GetDB().Select("config_dirty").Where("id = ?", nodeID).First(&already).Error; err == nil && already.ConfigDirty { - logger.Debugf("setRemoteTraffic: node %d %s; already dirty", nodeID, reason) + logger.Debugf("setRemoteTraffic: node %d lifecycle lift; already dirty", nodeID) } else { - logger.Infof("setRemoteTraffic: node %d %s; marking dirty for re-push", nodeID, reason) + logger.Infof("setRemoteTraffic: node %d lifecycle lift; marking dirty for re-push", nodeID) if err := (&NodeService{}).MarkNodeDirty(nodeID); err != nil { - logger.Warningf("setRemoteTraffic: mark node %d dirty after %s failed: %v", nodeID, reason, err) + logger.Warningf("setRemoteTraffic: mark node %d dirty after lifecycle lift failed: %v", nodeID, err) } } } diff --git a/internal/web/service/inbound_node_claims.go b/internal/web/service/inbound_node_claims.go index 1f2b5736b..2ade4c32b 100644 --- a/internal/web/service/inbound_node_claims.go +++ b/internal/web/service/inbound_node_claims.go @@ -28,6 +28,9 @@ type nodeClientClaims struct { // storedSubID keeps an own record's subId when the node reports one held by a // client it does not host. storedSubID map[string]string + // usageOnly records live only outside this node but share the email: the node's usage + // still adds to them (cross-panel totals), its lifecycle and record values do not. + usageOnly map[string]struct{} } // nodeKeptSharedKeys stay as the node reported them: per-link values, and the lifecycle @@ -41,6 +44,7 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot refused: map[string]string{}, shared: map[string]*model.ClientRecord{}, storedSubID: map[string]string{}, + usageOnly: map[string]struct{}{}, } subIDByEmail := map[string]string{} // A subId selects a whole subscription, so within one snapshot it may name one client only. @@ -154,6 +158,7 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot claims.shared[email] = rec case scope.elsewhere: claims.refused[email] = "it is attached only to inbounds this node does not host" + claims.usageOnly[email] = struct{}{} case !scope.here && (rec.SyncOrphanedAt == 0 || rec.SyncOrphanedBy != nodeID): claims.refused[email] = "it is detached from every inbound on the master" case subID != rec.SubID && subID != "" && subIDTaken(email, subID): @@ -334,3 +339,24 @@ func logOversizeNodeInbounds(nodeID int, tags map[string]string) { } reportedOversizeInbounds.Store(nodeID, tags) } + +// usageOnlyIn lists, lowercased as the stats merge keys them, the usage-only emails a +// snapshot inbound's own settings carry. +func (c *nodeClientClaims) usageOnlyIn(settings string) map[string]struct{} { + out := map[string]struct{}{} + if len(c.usageOnly) == 0 { + return out + } + _, entries, ok := splitSettingsClients(settings) + if !ok { + return out + } + for _, entry := range entries { + if sc, ok := readSettingsClient(entry); ok { + if _, only := c.usageOnly[sc.Email]; only { + out[strings.ToLower(sc.Email)] = struct{}{} + } + } + } + return out +} diff --git a/internal/web/service/node_foreign_client_test.go b/internal/web/service/node_foreign_client_test.go index 0d36eebb0..43605617f 100644 --- a/internal/web/service/node_foreign_client_test.go +++ b/internal/web/service/node_foreign_client_test.go @@ -191,8 +191,8 @@ func TestNodeCannotRewriteClientSharedWithMaster(t *testing.T) { } } -// A refused claim must leave no trace: the stored settings are re-synced later by -// auto-renew, and its stats would land on the victim's traffic row. +// A refused claim must not be stored (auto-renew re-syncs the settings later) nor move the +// client's lifecycle; usage under an email the master also serves still adds up across panels. func TestNodeRefusedClaimIsNotStoredOrCounted(t *testing.T) { db := initTrafficTestDB(t) svc := &InboundService{} @@ -206,7 +206,8 @@ func TestNodeRefusedClaimIsNotStoredOrCounted(t *testing.T) { hostile := `{"clients":[{"email":"victim@x","id":"","subId":"picked-by-node","enable":true},{"email":"victim@x ","subId":"padded","enable":true},{"email":"VICTIM@X","subId":"cased","enable":true},{"email":"own@x","enable":true}]}` for _, up := range []int64{100, 600} { syncNodeWithSettings(t, svc, 1, "n1-in", hostile, - xray.ClientTraffic{Email: "victim@x", Up: up, Enable: true}, + xray.ClientTraffic{Email: "victim@x", Up: up, Enable: false, ExpiryTime: 1}, + xray.ClientTraffic{Email: "victim@x ", Up: up, Enable: true}, xray.ClientTraffic{Email: "own@x", Enable: true}) } @@ -219,10 +220,13 @@ func TestNodeRefusedClaimIsNotStoredOrCounted(t *testing.T) { if n := countClientsWhere(t, db, "LOWER(email) LIKE ?", "victim%"); n != 1 { t.Fatalf("a padded or case-variant claim created a look-alike record: %d victim rows", n) } - var counted int64 - db.Model(xray.ClientTraffic{}).Where("LOWER(email) LIKE ? AND up > 0", "victim%").Count(&counted) - if counted != 0 { - t.Fatalf("refused claim's stats reached a victim traffic row: %d rows", counted) + if got := readTraffic(t, db, "victim@x"); got.Up != 500 || !got.Enable || got.ExpiryTime != 0 { + t.Fatalf("victim traffic up=%d enable=%v expiry=%d, want the node's usage (500) and the master's lifecycle (true, 0)", got.Up, got.Enable, got.ExpiryTime) + } + var lookalike int64 + db.Model(xray.ClientTraffic{}).Where("email <> ? AND LOWER(email) LIKE ?", "victim@x", "victim%").Count(&lookalike) + if lookalike != 0 { + t.Fatalf("a padded claim's stats created %d look-alike traffic rows", lookalike) } if !nodeHostsClient(t, db, "n1-in", "own@x") { t.Fatal("the node's own client was not adopted") @@ -332,8 +336,8 @@ func TestNodeTunnelDoesNotOwnSharedPeerKeys(t *testing.T) { } } -// An honest node's copy of a shared client differs only in timestamps and comment; treating -// that as a refused claim would mark the node dirty and re-push it on every tick. +// An honest node's copy of a shared client differs only in timestamps and comment; rewriting +// it to the record's JSON would churn the stored settings on every tick. func TestNodeSharedClientInAgreementIsNotRewritten(t *testing.T) { db := initTrafficTestDB(t) svc := &InboundService{} @@ -358,12 +362,8 @@ func TestNodeSharedClientInAgreementIsNotRewritten(t *testing.T) { `{"clients":[{"email":"shared@x","id":"11111111-1111-4111-8111-111111111111","subId":"shared-sub","enable":true,"created_at":1,"updated_at":2}]}`, xray.ClientTraffic{Email: "shared@x", Enable: true}) - var node model.Node - if err := db.Select("config_dirty").Where("id = ?", 1).First(&node).Error; err != nil { - t.Fatalf("read node: %v", err) - } - if node.ConfigDirty { - t.Fatal("a node in agreement with the master was marked dirty for a re-push") + if stored := storedInboundSettings(t, db, "n1-in"); !strings.Contains(stored, `"updated_at":2`) { + t.Fatalf("a node in agreement with the master had its entry rewritten: %s", stored) } }