fix(node): keep summing usage across panels for a client the node shares by email

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.
This commit is contained in:
MHSanaei
2026-11-01 10:25:14 +01:00
parent 9c0bf6938c
commit 0394996a30
3 changed files with 59 additions and 40 deletions
+18 -25
View File
@@ -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)
}
}
}
@@ -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
}
@@ -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)
}
}