Files
HY2XS_flamy/apps/service/cron_scheduler.go
T
founder b99be7d514 fix(v1): разблокировать сборку, починить жизненный цикл cron и закрыть каналы утечки
Сборка не собиралась: два контракта приёмки роняли её на корректном коде.

verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.

Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.

Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.

Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.

updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.

Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.

Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
  который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
  достижима с неаутентифицированного запроса; набор алгоритмов подписи
  зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
  проверявшая меньше, чем middleware.

Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
2026-08-29 21:37:50 +05:00

245 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
// Планировщик принадлежит ПРОЦЕССУ, а не HTTP-серверу.
//
// Что было. InitCron жил в middleware, вызывался из runServer и на каждом
// вызове создавал новый cron.New(). Ссылка на планировщик никуда не
// сохранялась, а releaseResource() закрывал только SQLite и Hysteria —
// cron.Stop() не вызывался нигде. При этом смена RESET_TRAFFIC_CRON делала
// StopServer(), из-за чего runServer возвращался, а точка входа в cmd.go
// крутила его в `for {}` и запускала всё заново.
//
// Итог: каждая смена расписания добавляла ЦЕЛЫЙ дублирующий набор джоб, а
// старое расписание сброса трафика продолжало работать. После двух правок
// «@monthly → @weekly → @monthly» на процессе висели три планировщика,
// утроенный CollectMetricsSnapshot и три разных расписания сброса
// одновременно. Плюс окно, в котором джобы старого планировщика били в уже
// закрытое соединение SQLite: releaseResource() отрабатывал раньше, чем
// следующий runServer успевал открыть базу.
//
// Правильная граница: фиксированные джобы регистрируются один раз за жизнь
// процесса, а расписание сброса трафика перепланируется на месте по своему
// EntryID. HTTP-сервер к смене настройки отношения не имеет вообще.
// cronShutdownTimeout ограничивает ожидание уже запущенных джоб при остановке.
// systemd по умолчанию даёт юниту 90 секунд, так что запас есть.
const cronShutdownTimeout = 10 * time.Second
var (
cronMu sync.Mutex
cronScheduler *cron.Cron
resetTrafficEntryID cron.EntryID
// cronOwnedJobs учитывает джобы, запущенные ВНЕ расписания.
//
// Стартовая уборка статистики намеренно выполняется в фоне: на большой базе
// она заметно долгая, и держать на ней запуск сервиса незачем. Но
// cron.Stop() ждёт только то, что запустил сам планировщик, поэтому
// необслуженная горутина переживала закрытие SQLite — ровно та же болезнь,
// от которой лечится весь этот файл, только меньшего масштаба.
cronOwnedJobs sync.WaitGroup
)
// ValidateResetTrafficCron проверяет выражение ТЕМ ЖЕ парсером, которым его
// потом будет разбирать runtime.
//
// cron.New() без опций собирает parser из Minute|Hour|Dom|Month|Dow|Descriptor,
// и ровно его же использует cron.ParseStandard. Поэтому «валидно на входе API»
// и «планируется в рантайме» здесь не могут разойтись — а разойтись они могли
// бы, если бы валидация была написана собственной регуляркой.
//
// Пустая строка — легальное значение и означает «автоматический сброс
// выключен»: в панели поле clearable, и оператор имеет право его очистить.
func ValidateResetTrafficCron(expression string) error {
trimmed := strings.TrimSpace(expression)
if trimmed == "" {
return nil
}
if _, err := cron.ParseStandard(trimmed); err != nil {
return fmt.Errorf("%s: невалидное cron-выражение %q: %v", constant.ResetTrafficCron, expression, err)
}
return nil
}
// InitCron поднимает единственный планировщик процесса.
func InitCron() error {
cronMu.Lock()
defer cronMu.Unlock()
if cronScheduler != nil {
return errors.New("cron scheduler is already running")
}
c := cron.New(cron.WithLocation(time.Now().Location()))
fixedJobs := []struct {
name string
spec string
job func()
}{
{"CronHandleAccount", "@every 30s", CronHandleAccount},
{"CollectMetricsSnapshot", "@every 10s", CollectMetricsSnapshot},
{"CleanupStatsRetention", "@every 1h", CleanupStatsRetention},
}
for _, fixed := range fixedJobs {
if _, err := c.AddFunc(fixed.spec, fixed.job); err != nil {
logrus.Errorf("cron add func %s err: %v", fixed.name, err)
return fmt.Errorf("cron add func %s err", fixed.name)
}
}
expression, err := storedResetTrafficCron()
if err != nil {
return err
}
if expression != "" {
id, addErr := c.AddFunc(expression, CronResetTraffic)
if addErr != nil {
// Старт НЕ прерывается, и это осознанное решение.
//
// Панель отдаёт не только операторский UI: на ней же висит
// /internal/hysteria/auth, куда Hysteria ходит при каждом
// подключении пира. Отказ старта из-за испорченной строки
// расписания положил бы подключения пользователей — цена
// несопоставима с отключённым плановым сбросом счётчиков.
//
// Молчаливой деградации при этом нет: запись через API теперь
// валидируется тем же парсером, поэтому попасть сюда можно только
// правкой базы в обход продукта, и об этом пишется ERROR.
logrus.Errorf(
"cron: сохранённое %s=%q невалидно (%v); плановый сброс трафика выключен до исправления настройки",
constant.ResetTrafficCron, expression, addErr,
)
} else {
resetTrafficEntryID = id
}
}
cronOwnedJobs.Add(1)
go func() {
defer cronOwnedJobs.Done()
CleanupStatsRetention()
}()
c.Start()
cronScheduler = c
return nil
}
// RescheduleResetTraffic переносит джобу сброса трафика на новое расписание.
//
// HTTP-сервер здесь не участвует. Раньше единственным способом применить новое
// расписание был перезапуск процесса через StopServer(), и именно он и плодил
// планировщики.
func RescheduleResetTraffic(expression string) error {
if err := ValidateResetTrafficCron(expression); err != nil {
return err
}
cronMu.Lock()
defer cronMu.Unlock()
if cronScheduler == nil {
return errors.New("cron scheduler is not running")
}
// Старая запись снимается всегда, даже если новая не будет добавлена:
// пустое выражение означает «сброс выключен», а не «оставить как было».
if resetTrafficEntryID != 0 {
cronScheduler.Remove(resetTrafficEntryID)
resetTrafficEntryID = 0
}
trimmed := strings.TrimSpace(expression)
if trimmed == "" {
return nil
}
// Ошибка здесь уже невозможна: выражение прошло тот же парсер выше.
// Проверка остаётся, чтобы расхождение двух парсеров не превратилось в
// молча пропавшую джобу.
id, err := cronScheduler.AddFunc(trimmed, CronResetTraffic)
if err != nil {
return fmt.Errorf("%s: не удалось запланировать %q: %v", constant.ResetTrafficCron, expression, err)
}
resetTrafficEntryID = id
return nil
}
// StopCron останавливает планировщик и дожидается уже запущенных джоб.
//
// Без этого джобы продолжали работать после закрытия SQLite: каждая из них
// ходит в базу, и остановка в обратном порядке (сначала планировщик, потом
// соединение) — часть контракта завершения процесса.
func StopCron() {
cronMu.Lock()
scheduler := cronScheduler
cronScheduler = nil
resetTrafficEntryID = 0
cronMu.Unlock()
if scheduler == nil {
return
}
// Дожидаемся обеих групп: и джоб, запущенных планировщиком, и фоновых,
// которые он не видит. Незавершённая джоба означает работу с базой, которую
// releaseResource закроет сразу после возврата отсюда.
drained := make(chan struct{})
go func() {
<-scheduler.Stop().Done()
cronOwnedJobs.Wait()
close(drained)
}()
select {
case <-drained:
case <-time.After(cronShutdownTimeout):
logrus.Warnf("cron: запущенные джобы не завершились за %s, продолжаем остановку", cronShutdownTimeout)
}
}
// ResetTrafficScheduled сообщает, запланирован ли сейчас сброс трафика.
// Существует ради тестов: иначе проверить, что старая запись действительно
// снята, а не просто добавлена рядом, можно было бы только по времени.
func ResetTrafficScheduled() bool {
cronMu.Lock()
defer cronMu.Unlock()
return resetTrafficEntryID != 0
}
// CronEntryCount возвращает число активных записей планировщика.
// Тоже ради тестов: накопление джоб — это именно рост этого числа.
func CronEntryCount() int {
cronMu.Lock()
defer cronMu.Unlock()
if cronScheduler == nil {
return 0
}
return len(cronScheduler.Entries())
}
func storedResetTrafficCron() (string, error) {
config, err := dao.GetConfig("key = ?", constant.ResetTrafficCron)
if err != nil {
return "", err
}
if config.Value == nil {
return "", nil
}
return strings.TrimSpace(*config.Value), nil
}