mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-03 17:07:15 +00:00
fix(clients): sync auto-renewal across inbounds (#6339)
* fix(clients): sync auto-renewal across inbounds Propagate the renewed shared traffic state to every inbound that carries the same client email. Restore each affected runtime user while keeping renewal counters and quota resets single-counted. * fix(clients): preserve manual disable during renewal
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAutoRenewClients_UpdatesEveryInboundForSharedEmail(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
svc := &InboundService{}
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
past := time.Now().Add(-48 * time.Hour).UnixMilli()
|
||||||
|
shared := model.Client{
|
||||||
|
Email: "shared@x", ID: "11111111-1111-1111-1111-111111111111",
|
||||||
|
Enable: false, Reset: 30, ExpiryTime: past,
|
||||||
|
}
|
||||||
|
ib1 := mkInbound(t, 30201, model.VLESS, clientsSettings(t, []model.Client{shared}))
|
||||||
|
ib2 := mkInbound(t, 30202, model.VLESS, clientsSettings(t, []model.Client{shared}))
|
||||||
|
for _, ib := range []*model.Inbound{ib1, ib2} {
|
||||||
|
if err := svc.clientService.SyncInbound(nil, ib.Id, []model.Client{shared}); err != nil {
|
||||||
|
t.Fatalf("SyncInbound %d: %v", ib.Id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Create(&xray.ClientTraffic{
|
||||||
|
InboundId: ib1.Id, Email: shared.Email, Enable: false,
|
||||||
|
Up: 100, Down: 200, Reset: 30, ExpiryTime: past,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed client_traffics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := newTrafficMutationBatch()
|
||||||
|
if _, count, err := svc.autoRenewClients(db, batch); err != nil {
|
||||||
|
t.Fatalf("autoRenewClients: %v", err)
|
||||||
|
} else if count != 1 {
|
||||||
|
t.Fatalf("renewed count = %d, want 1 shared client", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var traffic xray.ClientTraffic
|
||||||
|
if err := db.Where("email = ?", shared.Email).First(&traffic).Error; err != nil {
|
||||||
|
t.Fatalf("read client_traffics: %v", err)
|
||||||
|
}
|
||||||
|
if !traffic.Enable || traffic.ExpiryTime <= time.Now().UnixMilli() {
|
||||||
|
t.Fatalf("traffic state not renewed: enable=%v expiry=%d", traffic.Enable, traffic.ExpiryTime)
|
||||||
|
}
|
||||||
|
for _, ib := range []*model.Inbound{ib1, ib2} {
|
||||||
|
reloaded, err := svc.GetInbound(ib.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInbound %d: %v", ib.Id, err)
|
||||||
|
}
|
||||||
|
clients, err := svc.GetClients(reloaded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetClients %d: %v", ib.Id, err)
|
||||||
|
}
|
||||||
|
if len(clients) != 1 {
|
||||||
|
t.Fatalf("inbound %d clients = %d, want 1", ib.Id, len(clients))
|
||||||
|
}
|
||||||
|
if !clients[0].Enable || clients[0].ExpiryTime != traffic.ExpiryTime {
|
||||||
|
t.Errorf("inbound %d state = enable %v expiry %d, want true/%d", ib.Id, clients[0].Enable, clients[0].ExpiryTime, traffic.ExpiryTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := svc.clientService.GetRecordByEmail(nil, shared.Email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRecordByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if !record.Enable || record.ExpiryTime != traffic.ExpiryTime {
|
||||||
|
t.Errorf("clients row = enable %v expiry %d, want true/%d", record.Enable, record.ExpiryTime, traffic.ExpiryTime)
|
||||||
|
}
|
||||||
|
if len(batch.localPlans) != 2 {
|
||||||
|
t.Errorf("runtime add plans = %d, want one for each inbound", len(batch.localPlans))
|
||||||
|
}
|
||||||
|
planCountByInbound := make(map[int]int, len(batch.localPlans))
|
||||||
|
for _, plan := range batch.localPlans {
|
||||||
|
planCountByInbound[plan.inbound.Id]++
|
||||||
|
}
|
||||||
|
for _, ib := range []*model.Inbound{ib1, ib2} {
|
||||||
|
if planCountByInbound[ib.Id] != 1 {
|
||||||
|
t.Errorf("inbound %d runtime add plans = %d, want 1", ib.Id, planCountByInbound[ib.Id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoRenewClients_PreservesOperatorDisabledClient(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
svc := &InboundService{}
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
past := time.Now().Add(-48 * time.Hour).UnixMilli()
|
||||||
|
disabled := model.Client{
|
||||||
|
Email: "disabled@x", ID: "22222222-2222-2222-2222-222222222222",
|
||||||
|
Enable: false, Reset: 30, ExpiryTime: past,
|
||||||
|
}
|
||||||
|
ib := mkInbound(t, 30203, model.VLESS, clientsSettings(t, []model.Client{disabled}))
|
||||||
|
if err := svc.clientService.SyncInbound(nil, ib.Id, []model.Client{disabled}); err != nil {
|
||||||
|
t.Fatalf("SyncInbound: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&xray.ClientTraffic{
|
||||||
|
InboundId: ib.Id, Email: disabled.Email, Enable: true,
|
||||||
|
Up: 100, Down: 200, Reset: 30, ExpiryTime: past,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed client_traffics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := newTrafficMutationBatch()
|
||||||
|
if _, count, err := svc.autoRenewClients(db, batch); err != nil {
|
||||||
|
t.Fatalf("autoRenewClients: %v", err)
|
||||||
|
} else if count != 1 {
|
||||||
|
t.Fatalf("renewed count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
var traffic xray.ClientTraffic
|
||||||
|
if err := db.Where("email = ?", disabled.Email).First(&traffic).Error; err != nil {
|
||||||
|
t.Fatalf("read client_traffics: %v", err)
|
||||||
|
}
|
||||||
|
if !traffic.Enable || traffic.ExpiryTime <= time.Now().UnixMilli() {
|
||||||
|
t.Fatalf("traffic state not renewed: enable=%v expiry=%d", traffic.Enable, traffic.ExpiryTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded, err := svc.GetInbound(ib.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInbound: %v", err)
|
||||||
|
}
|
||||||
|
clients, err := svc.GetClients(reloaded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetClients: %v", err)
|
||||||
|
}
|
||||||
|
if len(clients) != 1 {
|
||||||
|
t.Fatalf("clients = %d, want 1", len(clients))
|
||||||
|
}
|
||||||
|
if clients[0].Enable {
|
||||||
|
t.Error("operator-disabled client was enabled in inbound settings")
|
||||||
|
}
|
||||||
|
if clients[0].ExpiryTime != traffic.ExpiryTime {
|
||||||
|
t.Errorf("settings expiry = %d, want %d", clients[0].ExpiryTime, traffic.ExpiryTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := svc.clientService.GetRecordByEmail(nil, disabled.Email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRecordByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if record.Enable {
|
||||||
|
t.Error("operator-disabled client was enabled in clients table")
|
||||||
|
}
|
||||||
|
if record.ExpiryTime != traffic.ExpiryTime {
|
||||||
|
t.Errorf("clients row expiry = %d, want %d", record.ExpiryTime, traffic.ExpiryTime)
|
||||||
|
}
|
||||||
|
if len(batch.localPlans) != 0 {
|
||||||
|
t.Errorf("runtime add plans = %d, want 0", len(batch.localPlans))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -368,10 +368,15 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
|
|||||||
var inbound_ids []int
|
var inbound_ids []int
|
||||||
var inbounds []*model.Inbound
|
var inbounds []*model.Inbound
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
type inboundClientKey struct {
|
||||||
|
inboundID int
|
||||||
|
email string
|
||||||
|
}
|
||||||
var clientsToAdd []struct {
|
var clientsToAdd []struct {
|
||||||
inbound model.Inbound
|
inbound model.Inbound
|
||||||
client map[string]any
|
client map[string]any
|
||||||
}
|
}
|
||||||
|
clientsToAddSet := make(map[inboundClientKey]struct{})
|
||||||
|
|
||||||
// Resolve the inbounds to renew through the client_inbounds link rather than
|
// Resolve the inbounds to renew through the client_inbounds link rather than
|
||||||
// client_traffics.inbound_id, which goes stale after an inbound is deleted and
|
// client_traffics.inbound_id, which goes stale after an inbound is deleted and
|
||||||
@@ -407,8 +412,13 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
|
|||||||
// instead of a linear scan of every expired row (O(clients × expired) per
|
// instead of a linear scan of every expired row (O(clients × expired) per
|
||||||
// inbound, quadratic at scale). Pointers keep the in-place mutation below.
|
// inbound, quadratic at scale). Pointers keep the in-place mutation below.
|
||||||
trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics))
|
trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics))
|
||||||
|
// Keep the pre-renewal quota state: the shared pointer becomes enabled while
|
||||||
|
// processing the first inbound, while an already-enabled row paired with
|
||||||
|
// disabled settings represents an operator-disabled client we must preserve.
|
||||||
|
trafficWasEnabled := make(map[string]bool, len(traffics))
|
||||||
for i := range traffics {
|
for i := range traffics {
|
||||||
trafficByEmail[traffics[i].Email] = traffics[i]
|
trafficByEmail[traffics[i].Email] = traffics[i]
|
||||||
|
trafficWasEnabled[traffics[i].Email] = traffics[i].Enable
|
||||||
}
|
}
|
||||||
renewedEmails := make([]string, 0, len(traffics))
|
renewedEmails := make([]string, 0, len(traffics))
|
||||||
for inbound_index := range inbounds {
|
for inbound_index := range inbounds {
|
||||||
@@ -453,32 +463,37 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
|
|||||||
}
|
}
|
||||||
renewals++
|
renewals++
|
||||||
}
|
}
|
||||||
if renewals == 0 {
|
if renewals > 0 {
|
||||||
continue
|
traffic.ExpiryTime = newExpiryTime
|
||||||
|
traffic.ResetCount += renewals
|
||||||
}
|
}
|
||||||
c["expiryTime"] = newExpiryTime
|
c["expiryTime"] = traffic.ExpiryTime
|
||||||
traffic.ExpiryTime = newExpiryTime
|
if traffic.ExpiryTime <= now {
|
||||||
traffic.ResetCount += renewals
|
|
||||||
if newExpiryTime <= now {
|
|
||||||
// Cap ran out mid-catch-up and the client is still expired: enabling it
|
// Cap ran out mid-catch-up and the client is still expired: enabling it
|
||||||
// for disableInvalidClients to undo adds and removes an xray user for nothing.
|
// for disableInvalidClients to undo adds and removes an xray user for nothing.
|
||||||
clients[client_index] = any(c)
|
clients[client_index] = any(c)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
traffic.Down = 0
|
if renewals > 0 {
|
||||||
traffic.Up = 0
|
traffic.Down = 0
|
||||||
renewedEmails = append(renewedEmails, email)
|
traffic.Up = 0
|
||||||
if !traffic.Enable {
|
renewedEmails = append(renewedEmails, email)
|
||||||
|
}
|
||||||
|
if !trafficWasEnabled[email] {
|
||||||
traffic.Enable = true
|
traffic.Enable = true
|
||||||
c["enable"] = true
|
c["enable"] = true
|
||||||
clientsToAdd = append(clientsToAdd,
|
key := inboundClientKey{inboundID: inbounds[inbound_index].Id, email: email}
|
||||||
struct {
|
if _, planned := clientsToAddSet[key]; !planned {
|
||||||
inbound model.Inbound
|
clientsToAddSet[key] = struct{}{}
|
||||||
client map[string]any
|
clientsToAdd = append(clientsToAdd,
|
||||||
}{
|
struct {
|
||||||
inbound: *inbounds[inbound_index],
|
inbound model.Inbound
|
||||||
client: apiUserFromClient(c, cipher),
|
client map[string]any
|
||||||
})
|
}{
|
||||||
|
inbound: *inbounds[inbound_index],
|
||||||
|
client: apiUserFromClient(c, cipher),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
clients[client_index] = any(c)
|
clients[client_index] = any(c)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user