fix(traffic): re-enable a quota-disabled inbound on its periodic reset (#6834)

An inbound with a total traffic cap is switched off once up + down
reaches it. Its periodic traffic reset then zeroes the counters but
never sets enable back, so after the first used-up period the inbound
stays off for good and the reset schedule does nothing for it.

The reset job now notes, before resetting, whether a local inbound was
off because of its own cap (and not expired), and switches it back on
through SetInboundEnable once the counters are zero. An inbound switched
off below its cap, or past its expiry, stays off.
This commit is contained in:
Jay Hemnani
2026-10-23 19:59:41 +02:00
committed by GitHub
parent 47be06358e
commit aedd8fac1c
2 changed files with 69 additions and 0 deletions
@@ -5,6 +5,7 @@ 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"
@@ -42,6 +43,13 @@ 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
@@ -87,11 +95,22 @@ func (j *PeriodicTrafficResetJob) resetInboundsOnSchedule() {
logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
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)
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)
}
}
resetClientErr := j.clientService.ResetAllClientTraffics(&j.inboundService, inbound.Id)
@@ -104,6 +123,10 @@ func (j *PeriodicTrafficResetJob) resetInboundsOnSchedule() {
}
})
if needRestart.Load() {
j.xrayService.SetToNeedRestart()
}
if count := resetCount.Load(); count > 0 {
logger.Infof("Periodic traffic reset completed: %d inbounds reset", count)
}
@@ -3,6 +3,9 @@ package job
import (
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestMonthlyResetDue(t *testing.T) {
@@ -29,3 +32,46 @@ func TestMonthlyResetDue(t *testing.T) {
})
}
}
func TestPeriodicTrafficResetReenablesQuotaDisabledInbound(t *testing.T) {
cases := []struct {
name string
total int64
expiryTime int64
want bool
}{
{"switched off by its own quota", 1000, 0, true},
{"switched off by hand below its quota", 100000, 0, false},
{"over quota but expired", 1000, time.Now().Add(-time.Hour).UnixMilli(), false},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
initResetJobDB(t)
db := database.GetDB()
ib := model.Inbound{
UserId: 1, Enable: true, Port: 41201 + i, Protocol: model.VLESS, Tag: "inbound-quota",
TrafficReset: "daily", Total: tc.total, ExpiryTime: tc.expiryTime, Up: 600, Down: 500,
Settings: `{"clients":[]}`,
}
if err := db.Create(&ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := db.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("enable", false).Error; err != nil {
t.Fatalf("disable inbound: %v", err)
}
NewPeriodicTrafficResetJob("daily", time.UTC).Run()
var got model.Inbound
if err := db.First(&got, ib.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
if got.Up != 0 || got.Down != 0 {
t.Fatalf("inbound traffic not reset: up=%d down=%d", got.Up, got.Down)
}
if got.Enable != tc.want {
t.Fatalf("inbound enable = %v after its reset, want %v", got.Enable, tc.want)
}
})
}
}