From aedd8fac1cf432bd4039f171aaab65cbd7521a3b Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:29:41 +0530 Subject: [PATCH] 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. --- .../web/job/periodic_traffic_reset_job.go | 23 ++++++++++ .../job/periodic_traffic_reset_job_test.go | 46 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/internal/web/job/periodic_traffic_reset_job.go b/internal/web/job/periodic_traffic_reset_job.go index 6cf7734a8..ffe1e2802 100644 --- a/internal/web/job/periodic_traffic_reset_job.go +++ b/internal/web/job/periodic_traffic_reset_job.go @@ -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) } diff --git a/internal/web/job/periodic_traffic_reset_job_test.go b/internal/web/job/periodic_traffic_reset_job_test.go index 2c7c19880..c4f90cab7 100644 --- a/internal/web/job/periodic_traffic_reset_job_test.go +++ b/internal/web/job/periodic_traffic_reset_job_test.go @@ -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) + } + }) + } +}