mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-23 19:37:13 +00:00
fix(node): stop a departed master's frozen traffic from disabling clients (#6113)
client_global_traffics rows are keyed by (master_guid, email) and are only
ever overwritten by a push from that same master. A master that stops
pushing — decommissioned, reinstalled under a fresh GUID, or detached from
the node — therefore leaves its last snapshot behind permanently.
depletedClientsCond's cross-panel EXISTS branch matched any such row, so a
node kept comparing a client's quota against counters frozen weeks earlier.
Once they exceeded the quota the node disabled the client on every traffic
poll, and the node -> master enable merge latched that off on the master too,
where nothing sets it back. The reported symptom is exactly this: a client at
11 GB of a 24 GB quota, enabled on two nodes, disabled on the third, which
still held a 27-day-old row from a previous master reporting 30 GB.
Bound both the enforcement predicate and the display overlay to rows a master
refreshed within globalTrafficFreshWindow. Masters push every 30s, so a live
master is never affected; a master that is merely unreachable for a while
keeps enforcing for a full day before its numbers are set aside.
The one-way enable merge that makes such a disable permanent on the master is
deliberate (12d84c2a, #4917) and is left alone.
This commit is contained in:
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -70,7 +71,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
|
||||
|
||||
// No global rows: the cross-panel EXISTS branch is skipped (#5392), but a
|
||||
// client over its local quota is still disabled.
|
||||
if got := depletedCond(db); got != depletedClientsCondLocal {
|
||||
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
|
||||
t.Fatalf("empty globals must use the local-only predicate")
|
||||
}
|
||||
seedClientRow(t, "local-cap", 1, 600, 600, 1000)
|
||||
@@ -85,11 +86,84 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
|
||||
if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "local-cap", Up: 1, Down: 1}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
if got := depletedCond(db); got != depletedClientsCond {
|
||||
if got, _ := depletedCond(db); got != depletedClientsCond {
|
||||
t.Fatalf("with globals present the cross-panel predicate must be used")
|
||||
}
|
||||
}
|
||||
|
||||
// A master that stopped pushing leaves its last snapshot behind forever. Those
|
||||
// frozen counters must not keep enforcing quota, or a client well inside its
|
||||
// limit is disabled on every traffic poll with no way back (#6113).
|
||||
func TestStaleGlobalTraffic_Ignored(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
staleAt := time.Now().Add(-globalTrafficFreshWindow - time.Hour).UnixMilli()
|
||||
seedStaleGlobal := func(t *testing.T, guid, email string, up, down int64) {
|
||||
t.Helper()
|
||||
if err := svc.AcceptGlobalTraffic(guid, []*xray.ClientTraffic{{Email: email, Up: up, Down: down}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic(%s): %v", guid, err)
|
||||
}
|
||||
if err := db.Model(&model.ClientGlobalTraffic{}).
|
||||
Where("master_guid = ? AND email = ?", guid, email).
|
||||
Update("updated_at", staleAt).Error; err != nil {
|
||||
t.Fatalf("age row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("stale row alone neither enforces nor selects the cross-panel predicate", func(t *testing.T) {
|
||||
// 200 of 1000 used locally, 1900 reported by a master gone for a day.
|
||||
seedClientRow(t, "cap", 1, 100, 100, 1000)
|
||||
seedStaleGlobal(t, "dead-master", "cap", 1000, 900)
|
||||
|
||||
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
|
||||
t.Fatalf("only stale globals must fall back to the local-only predicate")
|
||||
}
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 0 {
|
||||
t.Fatalf("stale global usage must not disable a client, disabled %d", count)
|
||||
}
|
||||
if got := readTraffic(t, db, "cap"); !got.Enable {
|
||||
t.Error("client within its local quota must stay enabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale row does not inflate the displayed total", func(t *testing.T) {
|
||||
rows := []*xray.ClientTraffic{{Email: "cap", Up: 100, Down: 100}}
|
||||
overlayGlobalTraffic(db, rows)
|
||||
if rows[0].Up != 100 || rows[0].Down != 100 {
|
||||
t.Errorf("stale global must not overlay display counters, got up=%d down=%d", rows[0].Up, rows[0].Down)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a live master still enforces alongside the stale row", func(t *testing.T) {
|
||||
// This is the #6113 shape: one dead master frozen over quota, one live
|
||||
// master well under it. Only the live one may decide.
|
||||
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 1, Down: 1}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
if got, _ := depletedCond(db); got != depletedClientsCond {
|
||||
t.Fatalf("a fresh global row must select the cross-panel predicate")
|
||||
}
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 0 {
|
||||
t.Fatalf("the live master reports usage well under quota, disabled %d", count)
|
||||
}
|
||||
|
||||
// And once the live master reports real depletion, it takes effect.
|
||||
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 600, Down: 500}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 1 {
|
||||
t.Fatalf("fresh cross-panel depletion must disable the client, disabled %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGlobalUsage_DisablesClient(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
Reference in New Issue
Block a user