mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
fix(traffic): restore quota-disabled inbounds when their traffic is reset (#6962)
An inbound switched off by its own traffic cap came back on the periodic reset, but the admin per-inbound reset and Reset All Traffic only zeroed the counters. After that nothing could tell it from a hand-disabled inbound, so it stayed off with every client on it. Both admin resets now re-enable the inbounds their cap switched off, using the predicate the periodic job used, now in the service next to disableInvalidInbounds; expired and hand-disabled inbounds stay off.
This commit is contained in:
@@ -340,11 +340,12 @@ func (a *InboundController) resetInboundTraffic(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err = a.inboundService.ResetInboundTraffic(id)
|
||||
needRestart, err := a.inboundService.ResetInboundTraffic(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
} else {
|
||||
}
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
|
||||
@@ -386,11 +387,12 @@ func (a *InboundController) delAllInboundClients(c *gin.Context) {
|
||||
|
||||
// resetAllTraffics resets all traffic counters across all inbounds.
|
||||
func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
||||
err := a.inboundService.ResetAllTraffics()
|
||||
needRestart, err := a.inboundService.ResetAllTraffics()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
} else {
|
||||
}
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
@@ -43,13 +42,6 @@ func monthlyResetDue(resetDay int, now time.Time) bool {
|
||||
return now.Day() == min(resetDay, lastDay)
|
||||
}
|
||||
|
||||
// inboundQuotaDisabled mirrors disableInvalidInbounds: a local inbound its own
|
||||
// traffic cap switched off, not one that expired or was switched off by hand.
|
||||
func inboundQuotaDisabled(ib *model.Inbound, nowMs int64) bool {
|
||||
return !ib.Enable && ib.NodeID == nil && ib.Total > 0 && ib.Up+ib.Down >= ib.Total &&
|
||||
(ib.ExpiryTime <= 0 || ib.ExpiryTime > nowMs)
|
||||
}
|
||||
|
||||
func forEachResetBounded(n int, reset func(i int)) {
|
||||
sem := make(chan struct{}, periodicResetConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
@@ -96,21 +88,13 @@ func (j *PeriodicTrafficResetJob) resetInboundsOnSchedule() {
|
||||
|
||||
var resetCount atomic.Int32
|
||||
var needRestart atomic.Bool
|
||||
nowMs := time.Now().UnixMilli()
|
||||
forEachResetBounded(len(inbounds), func(i int) {
|
||||
inbound := inbounds[i]
|
||||
quotaOff := inboundQuotaDisabled(inbound, nowMs)
|
||||
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
|
||||
nr, resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
|
||||
if resetInboundErr != nil {
|
||||
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
|
||||
} else if quotaOff {
|
||||
nr, err := j.inboundService.SetInboundEnable(inbound.Id, true)
|
||||
if err != nil {
|
||||
logger.Warning("Failed to re-enable inbound", inbound.Id, "after traffic reset:", err)
|
||||
}
|
||||
if nr {
|
||||
needRestart.Store(true)
|
||||
}
|
||||
} else if nr {
|
||||
needRestart.Store(true)
|
||||
}
|
||||
|
||||
nr, resetClientErr := j.clientService.ResetAllClientTraffics(&j.inboundService, inbound.Id)
|
||||
|
||||
@@ -105,8 +105,8 @@ func TestAllAPIsPostgresScale(t *testing.T) {
|
||||
run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
|
||||
run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
|
||||
|
||||
run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
|
||||
run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
|
||||
run("ResetInboundTraffic", func() error { _, err := inboundSvc.ResetInboundTraffic(ib.Id); return err })
|
||||
run("Inbound.ResetAllTraffics", func() error { _, err := inboundSvc.ResetAllTraffics(); return err })
|
||||
run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
|
||||
run("BulkResetTraffic(M)", func() error { _, err := svc.BulkResetTraffic(inboundSvc, emailsM); return err })
|
||||
|
||||
|
||||
@@ -35,6 +35,30 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB, mutationBatch *traf
|
||||
return false, count, err
|
||||
}
|
||||
|
||||
// quotaDisabledInboundCond matches local inbounds their own cap switched off (args: enable, nowMs);
|
||||
// expired or hand-disabled ones never match, so a reset cannot bring them back.
|
||||
const quotaDisabledInboundCond = `enable = ? AND node_id IS NULL AND total > 0 AND up + down >= total AND (expiry_time <= 0 OR expiry_time > ?)`
|
||||
|
||||
// quotaDisabledInboundIDs lists the inbounds a traffic cap switched off.
|
||||
func quotaDisabledInboundIDs(db *gorm.DB, nowMs int64) ([]int, error) {
|
||||
var ids []int
|
||||
err := db.Model(model.Inbound{}).
|
||||
Where("user_id > ?", 0).
|
||||
Where(quotaDisabledInboundCond, false, nowMs).
|
||||
Pluck("id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// quotaDisabledInbound reports whether a traffic cap switched this inbound off.
|
||||
func quotaDisabledInbound(db *gorm.DB, id int, nowMs int64) (bool, error) {
|
||||
var count int64
|
||||
err := db.Model(model.Inbound{}).
|
||||
Where("id = ?", id).
|
||||
Where(quotaDisabledInboundCond, false, nowMs).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
const globalTrafficFreshWindow = 24 * time.Hour
|
||||
|
||||
func globalTrafficFreshSince(now time.Time) int64 {
|
||||
|
||||
@@ -832,14 +832,33 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
return needRestart, nil, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) ResetAllTraffics() error {
|
||||
// ResetAllTraffics zeroes every inbound's counters and brings back the inbounds a traffic
|
||||
// cap switched off; left off, every client on them would stay dead.
|
||||
func (s *InboundService) ResetAllTraffics() (bool, error) {
|
||||
var restore []int
|
||||
err := submitTrafficWrite(func() error {
|
||||
db := database.GetDB()
|
||||
var err error
|
||||
restore, err = quotaDisabledInboundIDs(db, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.resetAllTrafficsLocked()
|
||||
})
|
||||
if err == nil {
|
||||
s.propagateResetAllTrafficsToNodes()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return err
|
||||
needRestart := false
|
||||
for _, id := range restore {
|
||||
nr, err := s.SetInboundEnable(id, true)
|
||||
if err != nil {
|
||||
logger.Warning("ResetAllTraffics: re-enable quota-disabled inbound", id, "failed:", err)
|
||||
continue
|
||||
}
|
||||
needRestart = needRestart || nr
|
||||
}
|
||||
s.propagateResetAllTrafficsToNodes()
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) resetAllTrafficsLocked() error {
|
||||
@@ -878,23 +897,37 @@ func (s *InboundService) propagateResetAllTrafficsToNodes() {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *InboundService) ResetInboundTraffic(id int) error {
|
||||
// ResetInboundTraffic zeroes one inbound's counters and brings it back when
|
||||
// its own traffic cap was what switched it off.
|
||||
func (s *InboundService) ResetInboundTraffic(id int) (bool, error) {
|
||||
var inbound *model.Inbound
|
||||
var quotaOff bool
|
||||
if err := submitTrafficWrite(func() error {
|
||||
db := database.GetDB()
|
||||
if err := db.Model(model.Inbound{}).
|
||||
Where("id = ?", id).
|
||||
var err error
|
||||
quotaOff, err = quotaDisabledInbound(db, id, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Model(model.Inbound{}).Where("id = ?", id).
|
||||
Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
inbound, err = s.GetInbound(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
needRestart := false
|
||||
if quotaOff {
|
||||
nr, err := s.SetInboundEnable(id, true)
|
||||
if err != nil {
|
||||
logger.Warning("ResetInboundTraffic: re-enable quota-disabled inbound", id, "failed:", err)
|
||||
}
|
||||
needRestart = needRestart || nr
|
||||
}
|
||||
if inbound != nil && inbound.NodeID != nil {
|
||||
if rt, rterr := s.runtimeFor(inbound); rterr == nil {
|
||||
@@ -905,7 +938,7 @@ func (s *InboundService) ResetInboundTraffic(id int) error {
|
||||
logger.Warning("ResetInboundTraffic: runtime lookup failed:", rterr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) DelDepletedClients(id int) (err error) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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/web/runtime"
|
||||
)
|
||||
|
||||
// setupQuotaRestoreTest wires a temp DB and a local runtime that accepts
|
||||
// inbound changes, so a reset can be driven end to end.
|
||||
func setupQuotaRestoreTest(t *testing.T) {
|
||||
t.Helper()
|
||||
initTrafficTestDB(t)
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
||||
mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
|
||||
runtime.SetManager(mgr)
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
}
|
||||
|
||||
// seedQuotaInbound creates an inbound in the state a traffic cap leaves it in:
|
||||
// switched off, with its counters past the cap.
|
||||
func seedQuotaInbound(t *testing.T, tag string, port int, total, up int64, expiryTime int64) *model.Inbound {
|
||||
t.Helper()
|
||||
ib := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: tag,
|
||||
Enable: false,
|
||||
Listen: "0.0.0.0",
|
||||
Port: port,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
Settings: `{}`,
|
||||
Total: total,
|
||||
Up: up,
|
||||
ExpiryTime: expiryTime,
|
||||
}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound %s: %v", tag, err)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
// A cap switches an inbound off and out of the core; zeroing the counters lifts
|
||||
// the cap, so the inbound must come back or every client on it stays down.
|
||||
func TestResetAllTrafficsRestoresQuotaDisabledInbound(t *testing.T) {
|
||||
setupQuotaRestoreTest(t)
|
||||
const gb = int64(1024 * 1024 * 1024)
|
||||
seedQuotaInbound(t, "capped", 45501, gb, gb, 0)
|
||||
|
||||
if _, err := (&InboundService{}).ResetAllTraffics(); err != nil {
|
||||
t.Fatalf("ResetAllTraffics: %v", err)
|
||||
}
|
||||
|
||||
after := loadInboundByTag(t, "capped")
|
||||
if !after.Enable {
|
||||
t.Fatal("an inbound whose traffic cap was lifted must be enabled again")
|
||||
}
|
||||
if after.Up != 0 || after.Down != 0 {
|
||||
t.Fatalf("counters = up %d / down %d, want both zero", after.Up, after.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetInboundTrafficRestoresQuotaDisabledInbound(t *testing.T) {
|
||||
setupQuotaRestoreTest(t)
|
||||
const gb = int64(1024 * 1024 * 1024)
|
||||
ib := seedQuotaInbound(t, "capped", 45502, gb, gb, 0)
|
||||
|
||||
if _, err := (&InboundService{}).ResetInboundTraffic(ib.Id); err != nil {
|
||||
t.Fatalf("ResetInboundTraffic: %v", err)
|
||||
}
|
||||
|
||||
after := loadInboundByTag(t, "capped")
|
||||
if !after.Enable {
|
||||
t.Fatal("an inbound whose traffic cap was lifted must be enabled again")
|
||||
}
|
||||
if after.Up != 0 || after.Down != 0 {
|
||||
t.Fatalf("counters = up %d / down %d, want both zero", after.Up, after.Down)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a cap may bring an inbound back. One an operator switched off by hand,
|
||||
// and one that expired, stay off — the reset has nothing to do with either.
|
||||
func TestResetAllTrafficsLeavesHandDisabledAndExpiredInboundsOff(t *testing.T) {
|
||||
setupQuotaRestoreTest(t)
|
||||
const gb = int64(1024 * 1024 * 1024)
|
||||
seedQuotaInbound(t, "hand-disabled", 45503, 0, 0, 0)
|
||||
seedQuotaInbound(t, "expired", 45504, gb, gb, time.Now().Add(-time.Hour).UnixMilli())
|
||||
|
||||
if _, err := (&InboundService{}).ResetAllTraffics(); err != nil {
|
||||
t.Fatalf("ResetAllTraffics: %v", err)
|
||||
}
|
||||
|
||||
for _, tag := range []string{"hand-disabled", "expired"} {
|
||||
if after := loadInboundByTag(t, tag); after.Enable {
|
||||
t.Fatalf("inbound %q must stay disabled", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestPanelResetsZeroSidecarQuota(t *testing.T) {
|
||||
// sidecar's quota counters must stay too or clients get their quota again free.
|
||||
func TestInboundResetAllKeepsSidecarQuota(t *testing.T) {
|
||||
_, logPath := startQuotaSidecar(t, 46204, model.Client{Enable: true})
|
||||
if err := (&InboundService{}).ResetAllTraffics(); err != nil {
|
||||
if _, err := (&InboundService{}).ResetAllTraffics(); err != nil {
|
||||
t.Fatalf("ResetAllTraffics: %v", err)
|
||||
}
|
||||
if got := quotaResets(t, logPath); len(got) != 0 {
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestResetAllTrafficsReachesNodesConcurrently(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_ = (&InboundService{}).ResetAllTraffics()
|
||||
_, _ = (&InboundService{}).ResetAllTraffics()
|
||||
}()
|
||||
t.Cleanup(func() { gate.open(); <-done })
|
||||
gate.waitAll(t, 3, "ResetAllTraffics")
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestResetAllTrafficsDoesNotBlockWriterOnNodeCall(t *testing.T) {
|
||||
mgr.SetRuntimeOverride(node.Id, fake)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- (&InboundService{}).ResetAllTraffics() }()
|
||||
go func() { _, err := (&InboundService{}).ResetAllTraffics(); done <- err }()
|
||||
|
||||
select {
|
||||
case <-fake.reached:
|
||||
|
||||
Reference in New Issue
Block a user