mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-06 06:02:09 +03:00
fix(node): ignore a node's stats for clients detached from the inbound
Invariant: a node snapshot may move a master client's traffic state only for emails the reporting inbound's settings still list. A master detach calls the node's /detach endpoint, which keeps the stat row (keepTraffic), and the node's GetInbounds preloads ClientStats by inbound_id alone, so the node goes on reporting the detached email under that inbound. setRemoteTrafficLocked merged every reported entry: it copied the node's stale total onto the master's per-email row, after which the matching limits made the node's enable=false look genuine and latched the client disabled on the master. A master quota edit only reaches inbounds the client is attached to, so the next sync (every 5s) wrote the stale quota back. The same unchecked loop also created a fresh client_traffics row for a reported email the master no longer had once its tombstone expired. Snapshot entries are now gated on the inbound's settings.clients. When the settings cannot be parsed, membership is unknown and every entry is merged as before. Closes #6724
This commit is contained in:
@@ -441,6 +441,20 @@ func snapshotDropsEveryHubClient(tx *gorm.DB, inboundID int, wireSettings string
|
||||
return links > 0
|
||||
}
|
||||
|
||||
// snapshotAttachedEmails lowercases the emails a snapshot inbound's settings list;
|
||||
// ok is false when the settings cannot be parsed, so membership is unknown.
|
||||
func snapshotAttachedEmails(settings string) (map[string]struct{}, bool) {
|
||||
clients, err := ParseInboundSettingsClients(settings)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
emails := make(map[string]struct{}, len(clients))
|
||||
for i := range clients {
|
||||
emails[strings.ToLower(clients[i].Email)] = struct{}{}
|
||||
}
|
||||
return emails, true
|
||||
}
|
||||
|
||||
// clientEmailsOwnedElsewhere returns the emails attached only to inbounds of
|
||||
// other nodes: email is unique, so adopting one would overwrite a client this
|
||||
// node does not serve. Attached nowhere means soft-orphaned, hence adoptable.
|
||||
@@ -942,7 +956,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
snapEmails := make(map[string]struct{}, len(snapIb.ClientStats))
|
||||
// Parsed once per inbound on the first renewal candidate, not per client.
|
||||
var snapExpiries map[string]int64
|
||||
attachedOnNode, membershipKnown := snapshotAttachedEmails(snapIb.Settings)
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
snapEmails[cs.Email] = struct{}{}
|
||||
|
||||
// Node-wide total, not this inbound's possibly-stale copy (#5274).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// A node detach keeps the client's stat row (keepTraffic), so the node goes on
|
||||
// reporting it under an inbound whose settings no longer list the client (#6724).
|
||||
func TestNodeSnapshotIgnoresStatsOfClientDetachedFromInbound(t *testing.T) {
|
||||
const gib = int64(1) << 30
|
||||
const emptyClients = `{"clients": []}`
|
||||
|
||||
t.Run("stale node quota and disable never reach the master row", func(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
local := &model.Inbound{UserId: 1, Tag: "local-in", Enable: true, Port: 41010, Protocol: model.VLESS}
|
||||
if err := db.Create(local).Error; err != nil {
|
||||
t.Fatalf("create local inbound: %v", err)
|
||||
}
|
||||
const email = "moved"
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: local.Id, Email: email, Enable: true, Total: 200 * gib, Up: 5, Down: 5}).Error; err != nil {
|
||||
t.Fatalf("seed client_traffics: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 40, Down: 40}).Error; err != nil {
|
||||
t.Fatalf("seed node baseline: %v", err)
|
||||
}
|
||||
|
||||
svc := &InboundService{}
|
||||
// Two ticks: the first copies the quota, which makes the second's disable look genuine.
|
||||
for range 2 {
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", emptyClients,
|
||||
xray.ClientTraffic{Email: email, Up: 40, Down: 40, Total: 100 * gib, Enable: false})
|
||||
}
|
||||
|
||||
got := readTraffic(t, db, email)
|
||||
if got.Total != 200*gib || !got.Enable {
|
||||
t.Fatalf("master row = total %d enable %v, want total %d enable true", got.Total, got.Enable, 200*gib)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a detached email gets no master traffic row", func(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
|
||||
svc := &InboundService{}
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", emptyClients,
|
||||
xray.ClientTraffic{Email: "gone", Up: 40, Down: 40, Total: 100 * gib, Enable: true})
|
||||
|
||||
var ct xray.ClientTraffic
|
||||
err := db.Model(xray.ClientTraffic{}).Where("email = ?", "gone").First(&ct).Error
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("client_traffics row for detached email: err = %v, row = %+v; want ErrRecordNotFound", err, ct)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user