Periodic traffic full reset

This commit is contained in:
Alireza Ahmadi 2026-08-21 15:39:06 +02:00
parent 01d6476f3f
commit 9bdd73c4ec
14 changed files with 511 additions and 11 deletions

36
util/cronspec/cronspec.go Normal file
View file

@ -0,0 +1,36 @@
// Package cronspec parses the cron expressions the panel accepts from an admin.
package cronspec
import (
"strings"
"github.com/alireza0/x-ui/util/common"
"github.com/robfig/cron/v3"
)
// Parser accepts a standard five-field cron expression, a six-field one with a
// leading seconds column, and the @descriptors. The panel's own scheduler is
// built with cron.WithSeconds(), whose parser rejects five-field expressions,
// so admin-supplied schedules are parsed here instead and handed to the
// scheduler as an already-parsed cron.Schedule.
var Parser = cron.NewParser(
cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
)
// Off is the value an admin can type to disable a schedule, alongside leaving
// the field empty.
const Off = "off"
// Parse returns the schedule for spec, or nil when the schedule is disabled.
func Parse(spec string) (cron.Schedule, error) {
trimmed := strings.TrimSpace(spec)
if trimmed == "" || strings.EqualFold(trimmed, Off) {
return nil, nil
}
schedule, err := Parser.Parse(trimmed)
if err != nil {
return nil, common.NewErrorf("cron schedule <%v> is not valid: %v", trimmed, err)
}
return schedule, nil
}

View file

@ -0,0 +1,107 @@
package cronspec
import (
"strings"
"testing"
"time"
)
func TestParseAcceptsSupportedForms(t *testing.T) {
tests := []struct {
name string
spec string
}{
{"daily descriptor", "@daily"},
{"weekly descriptor", "@weekly"},
{"monthly descriptor", "@monthly"},
{"midnight descriptor", "@midnight"},
{"every duration", "@every 12h"},
// The panel's own scheduler is built with cron.WithSeconds(), whose
// parser rejects this five-field form; parsing it here is the whole
// reason this package exists.
{"standard five-field", "0 0 * * *"},
{"five-field with a list", "30 3,15 * * 1-5"},
{"six-field with seconds", "0 0 0 * * *"},
{"surrounding whitespace", " @daily "},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
schedule, err := Parse(test.spec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if schedule == nil {
t.Fatal("a valid schedule parsed to nil, which means disabled")
}
if next := schedule.Next(time.Now()); next.Before(time.Now()) {
t.Fatalf("next occurrence %v is in the past", next)
}
})
}
}
func TestParseTreatsBlankAndOffAsDisabled(t *testing.T) {
for _, spec := range []string{"", " ", "off", "OFF", " Off "} {
schedule, err := Parse(spec)
if err != nil {
t.Fatalf("unexpected error for %q: %v", spec, err)
}
if schedule != nil {
t.Fatalf("%q should disable the schedule, got %v", spec, schedule)
}
}
}
func TestParseRejectsBadSpecs(t *testing.T) {
tests := []struct {
name string
spec string
}{
{"not a schedule", "every day"},
{"unknown descriptor", "@yearly-ish"},
{"too few fields", "0 0"},
{"field out of range", "0 99 * * *"},
{"bad duration", "@every banana"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := Parse(test.spec); err == nil {
t.Fatalf("expected %q to be rejected", test.spec)
}
})
}
}
// The message reaches the admin in the settings page, so it has to quote what
// they typed rather than just say the schedule is invalid.
func TestParseErrorQuotesTheSpec(t *testing.T) {
_, err := Parse("every day")
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), "every day") {
t.Fatalf("error %q does not quote the rejected spec", err.Error())
}
}
// @daily must land on local midnight, since that is what an admin selling a
// daily budget expects the day to roll over at.
func TestDailyLandsOnLocalMidnight(t *testing.T) {
schedule, err := Parse("@daily")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tehran, err := time.LoadLocation("Asia/Tehran")
if err != nil {
t.Skipf("time zone database unavailable: %v", err)
}
now := time.Date(2026, 8, 21, 14, 30, 0, 0, tehran)
next := schedule.Next(now)
if next.Hour() != 0 || next.Minute() != 0 {
t.Fatalf("next daily reset at %v, want midnight", next)
}
if next.Day() != 22 {
t.Fatalf("next daily reset on day %d, want the following day", next.Day())
}
}

View file

@ -7,6 +7,7 @@ import (
"time"
"github.com/alireza0/x-ui/util/common"
"github.com/alireza0/x-ui/util/cronspec"
"github.com/alireza0/x-ui/util/proxyclient"
"github.com/alireza0/x-ui/util/tgchat"
)
@ -56,6 +57,7 @@ type AllSetting struct {
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"`
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
GlobalReset string `json:"globalReset" form:"globalReset"`
IpBlockAfterRemove bool `json:"ipBlockAfterRemove" form:"ipBlockAfterRemove"`
}
@ -129,6 +131,10 @@ func (s *AllSetting) CheckValid() error {
return err
}
if _, err := cronspec.Parse(s.GlobalReset); err != nil {
return err
}
_, err := time.LoadLocation(s.TimeLocation)
if err != nil {
return common.NewError("time location not exist:", s.TimeLocation)

View file

@ -146,6 +146,9 @@
<setting-list-item type="text" title='{{ i18n "pages.settings.outboundTestUrl"}}'
desc='{{ i18n "pages.settings.outboundTestUrlDesc"}}'
v-model="allSetting.outboundTestUrl"></setting-list-item>
<setting-list-item type="text" title='{{ i18n "pages.settings.globalReset"}}'
desc='{{ i18n "pages.settings.globalResetDesc"}}'
v-model="allSetting.globalReset"></setting-list-item>
<setting-list-item type="switch" title='{{ i18n "pages.settings.ipBlockAfterRemove"}}'
desc='{{ i18n "pages.settings.ipBlockAfterRemoveDesc"}}'
v-model="allSetting.ipBlockAfterRemove"></setting-list-item>

View file

@ -0,0 +1,58 @@
package job
import (
"time"
"github.com/alireza0/x-ui/logger"
"github.com/alireza0/x-ui/web/service"
"github.com/robfig/cron/v3"
)
// ResetTrafficJob zeroes every client's traffic on a schedule the admin sets,
// so an "unlimited" plan can still be sold with a fair per-period budget.
type ResetTrafficJob struct {
inboundService service.InboundService
settingService service.SettingService
xrayService service.XrayService
schedule cron.Schedule
}
func NewResetTrafficJob(schedule cron.Schedule) *ResetTrafficJob {
return &ResetTrafficJob{schedule: schedule}
}
func (j *ResetTrafficJob) Run() {
loc, err := j.settingService.GetTimeLocation()
if err != nil {
logger.Warning("reset traffic: get time location failed:", err)
return
}
now := time.Now().In(loc)
next, err := j.settingService.GetGlobalResetLast()
if err != nil {
logger.Warning("reset traffic: get last reset time failed:", err)
return
}
// The panel may have been down across several boundaries, or the schedule
// may have been edited; either way one reset is enough, so wait until the
// boundary recorded last time is actually behind us.
if next > now.Unix() {
return
}
if err := j.inboundService.ResetAllClientTraffics(-1); err != nil {
logger.Warning("reset traffic: reset all clients failed:", err)
return
}
if err := j.settingService.SetGlobalResetLast(j.schedule.Next(now).Unix()); err != nil {
logger.Warning("reset traffic: set last reset time failed:", err)
}
// Clients that ran out are enabled again, and Xray only learns that from a
// fresh config.
j.xrayService.SetToNeedRestart()
logger.Info("reset traffic: all client traffics reset, next reset at ", j.schedule.Next(now).In(loc).Format(time.RFC3339))
}

View file

@ -0,0 +1,159 @@
package job
import (
"path/filepath"
"testing"
"time"
"github.com/alireza0/x-ui/database"
"github.com/alireza0/x-ui/util/cronspec"
"github.com/alireza0/x-ui/web/service"
"github.com/alireza0/x-ui/xray"
)
func newTestDatabase(t *testing.T) {
t.Helper()
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("init database: %v", err)
}
}
// seedClients writes two clients: one that ran out of traffic and was disabled,
// and one still running.
func seedClients(t *testing.T) {
t.Helper()
db := database.GetDB()
clients := []xray.ClientTraffic{
{InboundId: 1, Email: "depleted@example.com", Enable: false, Up: 60, Down: 40, Total: 100},
{InboundId: 1, Email: "active@example.com", Enable: true, Up: 5, Down: 5, Total: 100},
}
for i := range clients {
if err := db.Create(&clients[i]).Error; err != nil {
t.Fatalf("seed %s: %v", clients[i].Email, err)
}
}
}
func loadClient(t *testing.T, email string) xray.ClientTraffic {
t.Helper()
var client xray.ClientTraffic
if err := database.GetDB().Where("email = ?", email).First(&client).Error; err != nil {
t.Fatalf("load %s: %v", email, err)
}
return client
}
func newResetJob(t *testing.T, spec string) *ResetTrafficJob {
t.Helper()
schedule, err := cronspec.Parse(spec)
if err != nil {
t.Fatalf("parse %q: %v", spec, err)
}
if schedule == nil {
t.Fatalf("%q parsed as disabled", spec)
}
return NewResetTrafficJob(schedule)
}
func TestResetTrafficJobZeroesAndReEnables(t *testing.T) {
newTestDatabase(t)
seedClients(t)
newResetJob(t, "@daily").Run()
depleted := loadClient(t, "depleted@example.com")
if depleted.Up != 0 || depleted.Down != 0 {
t.Errorf("depleted client still has %d/%d traffic", depleted.Up, depleted.Down)
}
if !depleted.Enable {
t.Error("depleted client was not enabled again, so the reset bought it nothing")
}
active := loadClient(t, "active@example.com")
if active.Up != 0 || active.Down != 0 {
t.Errorf("active client still has %d/%d traffic", active.Up, active.Down)
}
if !active.Enable {
t.Error("active client was disabled by the reset")
}
}
func TestResetTrafficJobRecordsTheNextBoundary(t *testing.T) {
newTestDatabase(t)
seedClients(t)
settingService := service.SettingService{}
before := time.Now().Unix()
newResetJob(t, "@daily").Run()
next, err := settingService.GetGlobalResetLast()
if err != nil {
t.Fatalf("GetGlobalResetLast: %v", err)
}
if next <= before {
t.Fatalf("recorded boundary %d is not in the future", next)
}
if next > before+2*24*3600 {
t.Fatalf("recorded boundary %d is more than two days out for a daily schedule", next)
}
}
// The job fires on every cron tick, but a reset is only due once the recorded
// boundary has passed; otherwise a restart-heavy panel would reset repeatedly.
func TestResetTrafficJobWaitsForTheBoundary(t *testing.T) {
newTestDatabase(t)
seedClients(t)
settingService := service.SettingService{}
boundary := time.Now().Add(12 * time.Hour).Unix()
if err := settingService.SetGlobalResetLast(boundary); err != nil {
t.Fatalf("SetGlobalResetLast: %v", err)
}
newResetJob(t, "@daily").Run()
active := loadClient(t, "active@example.com")
if active.Up != 5 || active.Down != 5 {
t.Fatalf("traffic was reset before the boundary: %d/%d", active.Up, active.Down)
}
if depleted := loadClient(t, "depleted@example.com"); depleted.Enable {
t.Error("a depleted client was enabled before the boundary")
}
if got, _ := settingService.GetGlobalResetLast(); got != boundary {
t.Fatalf("boundary moved to %d, want it left at %d", got, boundary)
}
}
// Downtime spanning several periods must still cost exactly one reset, not one
// per missed period.
func TestResetTrafficJobResetsOnceAfterDowntime(t *testing.T) {
newTestDatabase(t)
seedClients(t)
settingService := service.SettingService{}
if err := settingService.SetGlobalResetLast(time.Now().Add(-72 * time.Hour).Unix()); err != nil {
t.Fatalf("SetGlobalResetLast: %v", err)
}
job := newResetJob(t, "@daily")
job.Run()
firstBoundary, _ := settingService.GetGlobalResetLast()
if firstBoundary <= time.Now().Unix() {
t.Fatalf("boundary %d did not snap forward past the missed periods", firstBoundary)
}
// Give the clients traffic again and run once more: nothing should happen.
db := database.GetDB()
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", "active@example.com").
Updates(map[string]interface{}{"up": 7, "down": 3}).Error; err != nil {
t.Fatalf("re-seed traffic: %v", err)
}
job.Run()
active := loadClient(t, "active@example.com")
if active.Up != 7 || active.Down != 3 {
t.Fatalf("a second run inside the same period reset the traffic: %d/%d", active.Up, active.Down)
}
}

View file

@ -60,6 +60,8 @@ var defaultValueMap = map[string]string{
"subJsonURI": "",
"subJsonMux": "",
"subJsonRules": "",
"globalReset": "",
"globalResetLast": "0",
"warp": "",
"ipBlockAfterRemove": "false",
}
@ -202,6 +204,18 @@ func (s *SettingService) setBool(key string, value bool) error {
return s.setString(key, strconv.FormatBool(value))
}
func (s *SettingService) getInt64(key string) (int64, error) {
str, err := s.getString(key)
if err != nil {
return 0, err
}
return strconv.ParseInt(str, 10, 64)
}
func (s *SettingService) setInt64(key string, value int64) error {
return s.setString(key, strconv.FormatInt(value, 10))
}
func (s *SettingService) getInt(key string) (int, error) {
str, err := s.getString(key)
if err != nil {
@ -242,6 +256,25 @@ func (s *SettingService) SetTgBotChatId(chatIds string) error {
return s.setString("tgBotChatId", chatIds)
}
// GetGlobalReset returns the cron schedule on which every client's traffic is
// reset, or an empty string when the feature is off.
func (s *SettingService) GetGlobalReset() (string, error) {
return s.getString("globalReset")
}
func (s *SettingService) SetGlobalReset(spec string) error {
return s.setString("globalReset", spec)
}
// GetGlobalResetLast returns the next boundary the reset job is waiting for.
func (s *SettingService) GetGlobalResetLast() (int64, error) {
return s.getInt64("globalResetLast")
}
func (s *SettingService) SetGlobalResetLast(at int64) error {
return s.setInt64("globalResetLast", at)
}
func (s *SettingService) GetTgBotProxy() (string, error) {
return s.getString("tgBotProxy")
}
@ -469,6 +502,15 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
return err
}
// The reset job waits for the boundary it recorded last time. Switching to a
// different schedule must not be held up by the old schedule's boundary, so
// forget it and let the new schedule pick the next one.
if previous, err := s.GetGlobalReset(); err == nil && previous != allSetting.GlobalReset {
if err := s.SetGlobalResetLast(0); err != nil {
return err
}
}
v := reflect.ValueOf(allSetting).Elem()
t := reflect.TypeOf(allSetting).Elem()
fields := reflect_util.GetFields(t)

View file

@ -18,6 +18,18 @@ func newSettingService(t *testing.T) *SettingService {
return &SettingService{}
}
// baselineSetting is the smallest settings object UpdateAllSetting accepts.
func baselineSetting() *entity.AllSetting {
return &entity.AllSetting{
WebPort: 2053,
SubPort: 2096,
WebBasePath: "/",
SubPath: "/sub/",
SubJsonPath: "/json/",
TimeLocation: "Asia/Tehran",
}
}
// A panel upgraded from an older version has no row for a newly added setting,
// so the getter has to fall back to the default instead of erroring out and
// stopping the bot from starting.
@ -64,22 +76,76 @@ func TestTgBotProxyRoundTrip(t *testing.T) {
}
}
// The reset job waits on the boundary it stored; keeping that boundary across a
// schedule change would leave a newly chosen, more frequent schedule inert until
// the old one's boundary finally passed.
func TestChangingGlobalResetClearsTheBoundary(t *testing.T) {
service := newSettingService(t)
if err := service.SetGlobalReset("@weekly"); err != nil {
t.Fatalf("SetGlobalReset: %v", err)
}
if err := service.SetGlobalResetLast(4102444800); err != nil { // far in the future
t.Fatalf("SetGlobalResetLast: %v", err)
}
setting := baselineSetting()
setting.GlobalReset = "@daily"
if err := service.UpdateAllSetting(setting); err != nil {
t.Fatalf("UpdateAllSetting: %v", err)
}
if got, _ := service.GetGlobalReset(); got != "@daily" {
t.Fatalf("schedule = %q, want @daily", got)
}
if got, _ := service.GetGlobalResetLast(); got != 0 {
t.Fatalf("boundary = %d, want it cleared so the new schedule applies", got)
}
}
// An unchanged schedule must keep its boundary, or every unrelated settings save
// would grant an extra reset.
func TestSavingSettingsKeepsAnUnchangedResetBoundary(t *testing.T) {
service := newSettingService(t)
if err := service.SetGlobalReset("@daily"); err != nil {
t.Fatalf("SetGlobalReset: %v", err)
}
if err := service.SetGlobalResetLast(4102444800); err != nil {
t.Fatalf("SetGlobalResetLast: %v", err)
}
setting := baselineSetting()
setting.GlobalReset = "@daily"
setting.PageSize = 25 // an unrelated change
if err := service.UpdateAllSetting(setting); err != nil {
t.Fatalf("UpdateAllSetting: %v", err)
}
if got, _ := service.GetGlobalResetLast(); got != 4102444800 {
t.Fatalf("boundary = %d, want it untouched", got)
}
}
func TestUpdateAllSettingRejectsBadResetSchedule(t *testing.T) {
service := newSettingService(t)
setting := baselineSetting()
setting.GlobalReset = "every day"
if err := service.UpdateAllSetting(setting); err == nil {
t.Fatal("an invalid cron schedule was accepted")
}
}
// UpdateAllSetting is what the settings page calls; it must persist the new
// fields and refuse an invalid one without writing anything.
func TestUpdateAllSettingPersistsTelegramFields(t *testing.T) {
service := newSettingService(t)
setting := &entity.AllSetting{
WebPort: 2053,
SubPort: 2096,
WebBasePath: "/",
SubPath: "/sub/",
SubJsonPath: "/json/",
TimeLocation: "Asia/Tehran",
TgBotProxy: "socks5://127.0.0.1:1080",
TgBotChatId: "-1001234567890:42",
TgBotNotifyOnly: true,
}
setting := baselineSetting()
setting.TgBotProxy = "socks5://127.0.0.1:1080"
setting.TgBotChatId = "-1001234567890:42"
setting.TgBotNotifyOnly = true
if err := service.UpdateAllSetting(setting); err != nil {
t.Fatalf("UpdateAllSetting: %v", err)
}

View file

@ -537,6 +537,8 @@
"timeZoneDesc" = "Scheduled tasks will run based on this time zone."
"outboundTestUrl" = "Outbound Test URL"
"outboundTestUrlDesc" = "URL used to test outbound connectivity and latency. A lightweight endpoint that returns 204 is recommended."
"globalReset" = "Periodic Traffic Reset"
"globalResetDesc" = "Reset every client traffic counter on a schedule, so an unlimited plan can still carry a fair per-period budget. Depleted clients are enabled again. Leave empty or type off to disable. Accepts @daily, @weekly, @monthly or a cron expression such as 0 0 * * * . Uses the panel time zone and takes effect after a panel restart."
"ipBlockAfterRemove" = "Block IPs after Client Removal"
"ipBlockAfterRemoveDesc" = "Immediately block connected IPs when a client is removed, disabled, or depleted. Requires app restart to take effect."
"subSettings" = "Subscription"

View file

@ -537,6 +537,8 @@
"timeZoneDesc" = "وظایف برنامه ریزی شده بر اساس این منطقه‌زمانی اجرا می‌شود"
"outboundTestUrl" = "آدرس تست خروجی"
"outboundTestUrlDesc" = "آدرسی که برای تست اتصال و تأخیر خروجی‌ها استفاده می‌شود. یک نقطهٔ سبک که کد ۲۰۴ برمی‌گرداند توصیه می‌شود."
"globalReset" = "ریست دوره‌ای ترافیک"
"globalResetDesc" = "ترافیک همه‌ی کاربران را طبق یک زمان‌بندی صفر می‌کند، تا بتوان پلن نامحدود را با سقف منصفانه‌ی هر دوره فروخت. کاربرانی که حجمشان تمام شده دوباره فعال می‌شوند. برای غیرفعال کردن خالی بگذارید یا off بنویسید. مقادیر مجاز: @daily یا @weekly یا @monthly یا یک عبارت کرون مانند 0 0 * * * . از منطقه‌ی زمانی پنل استفاده می‌کند و پس از ری‌استارت پنل اعمال می‌شود."
"ipBlockAfterRemove" = "مسدودسازی IP پس از حذف کلاینت"
"ipBlockAfterRemoveDesc" = "پس از حذف، غیرفعال‌سازی یا اتمام ترافیک کلاینت، آدرس‌های IP متصل را فوراً مسدود می‌کند. برای اعمال تغییر، راه‌اندازی مجدد برنامه لازم است."
"subSettings" = "سابسکریپشن"

View file

@ -537,6 +537,8 @@
"timeZoneDesc" = "Запланированные задания выполняются в соответствии со временем в данном часовом поясе."
"outboundTestUrl" = "URL для теста исходящих"
"outboundTestUrlDesc" = "URL для проверки соединения и задержки исходящих. Рекомендуется лёгкий эндпоинт, возвращающий 204."
"globalReset" = "Периодический сброс трафика"
"globalResetDesc" = "Сбрасывает трафик всех клиентов по расписанию, чтобы безлимитный тариф всё же имел честный лимит на период. Исчерпавшие лимит клиенты снова включаются. Оставьте пустым или введите off, чтобы отключить. Принимает @daily, @weekly, @monthly или cron-выражение вида 0 0 * * * . Использует часовой пояс панели, применяется после перезапуска панели."
"ipBlockAfterRemove" = "Block IPs after Client Removal"
"ipBlockAfterRemoveDesc" = "Immediately block connected IPs when a client is removed, disabled, or depleted. Requires app restart to take effect."
"subSettings" = "Подписка"

View file

@ -537,6 +537,8 @@
"timeZoneDesc" = "Các tác vụ được lên lịch chạy theo thời gian trong múi giờ này."
"outboundTestUrl" = "URL kiểm tra outbound"
"outboundTestUrlDesc" = "URL dùng để kiểm tra kết nối và độ trễ outbound. Nên dùng endpoint nhẹ trả về 204."
"globalReset" = "Đặt lại lưu lượng định kỳ"
"globalResetDesc" = "Đặt lại lưu lượng của mọi khách hàng theo lịch, để gói không giới hạn vẫn có hạn mức hợp lý cho mỗi kỳ. Khách hàng đã dùng hết sẽ được bật lại. Để trống hoặc nhập off để tắt. Chấp nhận @daily, @weekly, @monthly hoặc biểu thức cron như 0 0 * * * . Dùng múi giờ của bảng điều khiển và có hiệu lực sau khi khởi động lại bảng điều khiển."
"ipBlockAfterRemove" = "Block IPs after Client Removal"
"ipBlockAfterRemoveDesc" = "Immediately block connected IPs when a client is removed, disabled, or depleted. Requires app restart to take effect."
"subSettings" = "Đăng ký"

View file

@ -537,6 +537,8 @@
"timeZoneDesc" = "定时任务按照该时区的时间运行"
"outboundTestUrl" = "出站测试网址"
"outboundTestUrlDesc" = "用于测试出站连接和延迟的网址。建议使用返回 204 的轻量级端点。"
"globalReset" = "定期流量重置"
"globalResetDesc" = "按计划重置所有客户端的流量,使不限量套餐仍能保有合理的周期额度。已用尽的客户端会被重新启用。留空或填写 off 即可关闭。支持 @daily、@weekly、@monthly 或 0 0 * * * 这样的 cron 表达式。使用面板时区,重启面板后生效。"
"ipBlockAfterRemove" = "Block IPs after Client Removal"
"ipBlockAfterRemoveDesc" = "Immediately block connected IPs when a client is removed, disabled, or depleted. Requires app restart to take effect."
"subSettings" = "订阅"

View file

@ -18,6 +18,7 @@ import (
"github.com/alireza0/x-ui/iplimit"
"github.com/alireza0/x-ui/logger"
"github.com/alireza0/x-ui/util/common"
"github.com/alireza0/x-ui/util/cronspec"
"github.com/alireza0/x-ui/web/controller"
"github.com/alireza0/x-ui/web/job"
"github.com/alireza0/x-ui/web/locale"
@ -255,6 +256,18 @@ func (s *Server) startTask() {
s.cron.AddJob("@every 10s", job.NewXrayTrafficJob())
}()
// Periodic reset of every client's traffic, when the admin configured one.
if spec, err := s.settingService.GetGlobalReset(); err != nil {
logger.Warning("get global reset schedule failed:", err)
} else if schedule, err := cronspec.Parse(spec); err != nil {
logger.Warning("global reset schedule ignored:", err)
} else if schedule != nil {
// The scheduler's own parser demands a seconds field, so hand it the
// schedule already parsed rather than the admin's expression.
s.cron.Schedule(schedule, job.NewResetTrafficJob(schedule))
logger.Info("global traffic reset enabled, schedule: ", spec)
}
// Make a traffic condition every day, 8:30
var entry cron.EntryID
isTgbotenabled, err := s.settingService.GetTgbotenabled()