mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-29 06:31:58 +00:00
Invariant: a client traffic reset zeroes every counter that enforces the client's quota - the master's, each hosting node's, and the local MTProto sidecar's. A node cuts a client on its own local counters, and the master adopts that verdict when both judged the same limits (#4917). Node counters: ResetClientTraffic tried the node once and dropped a failure ("nothing replays a reset"); BulkResetTraffic, ResetAllClientTraffics and ClientService.ResetAllTraffics never told the node at all. The node kept its pre-reset usage, switched the client off again on its next tick, and the master latched that - a client shown at zero usage stayed disabled. Every reset path now queues a node_pending_resets row per hosting node in its own transaction. It is delivered right after commit (per-client endpoint up to the push threshold, bulkResetTraffic above) and replayed by the node sync ahead of its snapshot, and dropped only once the node accepted it. While one is owed, the merge takes only that client's usage from the node, not its enable or limits. Deliveries to a node are serialized so a reset is not sent twice. Sidecar quota: BulkResetTraffic, ClientService.ResetAllTraffics and auto-renew zeroed the panel counters but not mtg's own quota counter, so the sidecar kept refusing a renewed or reset MTProto client. They now reset it too, scoped to the affected clients.
101 lines
3.8 KiB
Go
101 lines
3.8 KiB
Go
package service
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
// startQuotaSidecar runs a local MTProto inbound for mtga and mtgb under the fake
|
|
// mtg and returns its API log once the sidecar answers.
|
|
func startQuotaSidecar(t *testing.T, port int, mtga model.Client) (*model.Inbound, string) {
|
|
t.Helper()
|
|
setupConflictDB(t)
|
|
pidFile, logPath := installFakeMtgAPI(t)
|
|
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
|
t.Cleanup(func() { runtime.SetManager(nil) })
|
|
|
|
mtga.Email, mtga.Secret = "mtga", mtprotoTestSecretA
|
|
clients := []model.Client{mtga, {Email: "mtgb", Secret: mtprotoTestSecretB, Enable: true}}
|
|
ib := &model.Inbound{Tag: "mt-quota", Enable: true, Port: port, Protocol: model.MTProto, Settings: clientsSettings(t, clients)}
|
|
if err := database.GetDB().Create(ib).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
|
|
t.Fatalf("SyncInbound: %v", err)
|
|
}
|
|
for _, c := range clients {
|
|
row := xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true, Up: 5, Total: c.TotalGB, ExpiryTime: c.ExpiryTime, Reset: c.Reset}
|
|
if err := database.GetDB().Create(&row).Error; err != nil {
|
|
t.Fatalf("seed traffic: %v", err)
|
|
}
|
|
}
|
|
// A running sidecar needs a served client, so prime with the healthy set.
|
|
inst, ok := mtproto.InstanceFromInbound(&model.Inbound{
|
|
Id: ib.Id, Tag: ib.Tag, Port: port, Protocol: model.MTProto,
|
|
Settings: clientsSettings(t, []model.Client{{Email: "mtgb", Secret: mtprotoTestSecretB, Enable: true}}),
|
|
})
|
|
if !ok {
|
|
t.Fatal("seed inbound must produce an mtg instance")
|
|
}
|
|
if err := mtproto.GetManager().Ensure(inst); err != nil {
|
|
t.Fatalf("start mtg: %v", err)
|
|
}
|
|
t.Cleanup(func() { mtproto.GetManager().Remove(ib.Id) })
|
|
waitForSpawns(t, pidFile, 1)
|
|
waitFakeMtgLog(t, logPath, "ready")
|
|
return ib, logPath
|
|
}
|
|
|
|
func quotaResets(t *testing.T, logPath string) []string {
|
|
t.Helper()
|
|
var out []string
|
|
for _, line := range fakeMtgLog(t, logPath) {
|
|
if name, ok := strings.CutPrefix(line, "reset:"); ok {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
// Every path that zeroes a client's panel counters must zero the sidecar's own
|
|
// quota counter too, or the sidecar keeps refusing the client.
|
|
func TestPanelResetsZeroSidecarQuota(t *testing.T) {
|
|
t.Run("bulk reset", func(t *testing.T) {
|
|
_, logPath := startQuotaSidecar(t, 46201, model.Client{Enable: true})
|
|
if _, err := (&ClientService{}).BulkResetTraffic(&InboundService{}, []string{"mtga"}); err != nil {
|
|
t.Fatalf("BulkResetTraffic: %v", err)
|
|
}
|
|
if got := quotaResets(t, logPath); !slices.Equal(got, []string{"mtga"}) {
|
|
t.Fatalf("sidecar quota resets %v, want [mtga]", got)
|
|
}
|
|
})
|
|
t.Run("reset all", func(t *testing.T) {
|
|
_, logPath := startQuotaSidecar(t, 46202, model.Client{Enable: true})
|
|
if _, err := (&ClientService{}).ResetAllTraffics(); err != nil {
|
|
t.Fatalf("ResetAllTraffics: %v", err)
|
|
}
|
|
if got := quotaResets(t, logPath); !slices.Equal(got, []string{"mtga", "mtgb"}) {
|
|
t.Fatalf("sidecar quota resets %v, want [mtga mtgb]", got)
|
|
}
|
|
})
|
|
t.Run("auto renew", func(t *testing.T) {
|
|
expired := time.Now().Add(-time.Hour).UnixMilli()
|
|
_, logPath := startQuotaSidecar(t, 46203, model.Client{Enable: true, Reset: 30, ExpiryTime: expired})
|
|
if _, _, err := (&InboundService{}).AddTraffic(nil, nil); err != nil {
|
|
t.Fatalf("AddTraffic: %v", err)
|
|
}
|
|
if got := quotaResets(t, logPath); !slices.Equal(got, []string{"mtga"}) {
|
|
t.Fatalf("sidecar quota resets %v, want [mtga]", got)
|
|
}
|
|
})
|
|
}
|