diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 8d4a6d209..6c56a8cd8 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -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). diff --git a/internal/web/service/node_detached_client_stats_test.go b/internal/web/service/node_detached_client_stats_test.go new file mode 100644 index 000000000..f34e1a6a9 --- /dev/null +++ b/internal/web/service/node_detached_client_stats_test.go @@ -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) + } + }) +}