Files
3x-ui/internal/web/service/node_detached_client_stats_test.go
T
MHSanaei dd9569478e 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
2026-10-05 20:37:35 +02:00

62 lines
2.2 KiB
Go

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)
}
})
}