mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 03:05:02 +03:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user