fix(settings): accept five-field crontab for report and LDAP schedules

The panel scheduler runs WithSeconds(), whose parser rejects a standard
five-field crontab such as "30 8 * * *". An admin who typed one into the
Telegram/Discord notify time or the LDAP sync schedule saved it without
error, AddJob then failed at startup, and the job silently never ran.

Admin-typed schedules now go through internal/util/cronspec, which takes
an optional seconds column plus the @descriptors and hands the scheduler
a parsed cron.Schedule. CheckValid rejects an unparseable schedule on
save, and a stored legacy value falls back to the job's default instead
of dropping the job.
This commit is contained in:
MHSanaei
2026-10-11 20:33:08 +02:00
parent 0466e8b0bd
commit b124af2a1b
7 changed files with 179 additions and 44 deletions
+29 -34
View File
@@ -24,6 +24,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/cronspec"
"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
"github.com/mhsanaei/3x-ui/v3/internal/web/controller"
"github.com/mhsanaei/3x-ui/v3/internal/web/job"
@@ -385,13 +386,9 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
// LDAP sync scheduling
if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled {
runtime, err := s.settingService.GetLdapSyncCron()
if err != nil || runtime == "" {
runtime = "@every 1m"
}
j := job.NewLdapSyncJob()
runtime, _ := s.settingService.GetLdapSyncCron()
// job has zero-value services with method receivers that read settings on demand
_, _ = s.cron.AddJob(runtime, j)
scheduleSettingJob(s.cron, "LDAP sync", runtime, "@every 1m", job.NewLdapSyncJob())
}
// Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup.
@@ -399,16 +396,9 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
if (err == nil) && isTgbotenabled {
runtime, err := s.settingService.GetTgbotRuntime()
if err != nil {
logger.Warningf("Add NewStatsNotifyJob: failed to load runtime: %v; using default @daily", err)
runtime = "@daily"
} else if strings.TrimSpace(runtime) == "" {
logger.Warning("Add NewStatsNotifyJob runtime is empty, using default @daily")
runtime = "@daily"
}
logger.Infof("Tg notify enabled,run at %s", runtime)
if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil {
logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err)
logger.Warningf("Telegram notify: failed to load runtime: %v", err)
}
scheduleSettingJob(s.cron, "Telegram notify", runtime, "@daily", job.NewStatsNotifyJob())
// check for Telegram bot callback query hash storage reset
_, _ = s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob())
@@ -419,18 +409,9 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
if (err == nil) && isDiscordEnabled {
runtime, err := s.settingService.GetDiscordRunTime()
if err != nil {
logger.Warningf("Add NewDiscordNotifyJob: failed to load runtime: %v; using default @daily", err)
runtime = "@daily"
} else if strings.TrimSpace(runtime) == "" {
logger.Warning("Add NewDiscordNotifyJob runtime is empty, using default @daily")
runtime = "@daily"
}
logger.Infof("Discord notify enabled, run at %s", runtime)
if entryID, err := s.cron.AddJob(runtime, job.NewDiscordNotifyJob(s.discordService)); err != nil {
logger.Warningf("Add NewDiscordNotifyJob: failed to schedule runtime %q: %v", runtime, err)
} else {
s.discordNotifyEntryID = entryID
logger.Warningf("Discord notify: failed to load runtime: %v", err)
}
s.discordNotifyEntryID = scheduleSettingJob(s.cron, "Discord notify", runtime, "@daily", job.NewDiscordNotifyJob(s.discordService))
}
// CPU monitor publishes cpu.high events; register it whenever any notifier
@@ -710,16 +691,10 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
return
}
runtime, err := s.settingService.GetDiscordRunTime()
if err != nil || strings.TrimSpace(runtime) == "" {
runtime = "@daily"
}
entryID, err := s.cron.AddJob(runtime, job.NewDiscordNotifyJob(s.discordService))
if err != nil {
logger.Warningf("Reload Discord notify: failed to schedule runtime %q: %v", runtime, err)
} else {
s.discordNotifyEntryID = entryID
logger.Infof("Discord notify rescheduled, run at %s", runtime)
logger.Warningf("Discord notify: failed to load runtime: %v", err)
}
s.discordNotifyEntryID = scheduleSettingJob(s.cron, "Discord notify", runtime, "@daily", job.NewDiscordNotifyJob(s.discordService))
if s.discordGateway != nil && !s.discordGateway.IsRunning() {
_ = s.discordGateway.Start(s.ctx)
@@ -859,3 +834,23 @@ func (s *Server) GetWSHub() any {
func (s *Server) RestartXray() error {
return s.xrayService.RestartXray(true)
}
// scheduleSettingJob schedules j on an admin-typed spec, running it on fallback
// when the spec is empty or a legacy value that no longer parses.
func scheduleSettingJob(c *cron.Cron, name, spec, fallback string, j cron.Job) cron.EntryID {
if strings.TrimSpace(spec) == "" {
logger.Warningf("%s: schedule is empty, using default %s", name, fallback)
spec = fallback
}
schedule, err := cronspec.Parse(spec)
if err != nil {
logger.Warningf("%s: %v; using default %s", name, err, fallback)
spec = fallback
if schedule, err = cronspec.Parse(fallback); err != nil {
logger.Errorf("%s: default schedule %q: %v", name, fallback, err)
return 0
}
}
logger.Infof("%s scheduled, run at %s", name, spec)
return c.Schedule(schedule, j)
}