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 пакета.
This commit is contained in:
2026-08-29 21:37:38 +05:00
parent 672d455467
commit b99be7d514
50 changed files with 3907 additions and 1622 deletions
+87 -36
View File
@@ -2,59 +2,110 @@ package service
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"strings"
"time"
)
// Токены подписываются и проверяются библиотекой golang-jwt/jwt/v5.
//
// Переход с v3 не косметический. У `github.com/golang-jwt/jwt` v3.2.2 есть
// GO-2025-3553 (чрезмерное выделение памяти при разборе заголовка), и у неё
// НЕТ исправленной версии в ветке v3: `Fixed in: N/A`. Уязвимый код
// достигается из ParseToken, то есть с неаутентифицированного запроса — любой,
// кто может обратиться к панели, попадает в него до всякой проверки прав.
// Обновить здесь было нечего, поэтому лечится только сменой мажорной ветки.
//
// Заодно закрыт второй, тихий недостаток прежней реализации: keyfunc возвращал
// ключ, НЕ проверяя алгоритм подписи. Разбор без ограничения набора алгоритмов
// — это тот класс ошибок, из которого растёт подмена алгоритма; ниже стоит
// jwt.WithValidMethods, и допустимый алгоритм ровно один.
const TokenExpireDuration = time.Hour * 24
// tokenIssuer попадает и в подпись, и в проверку: токен, выписанный не нами,
// не должен разбираться как наш даже при совпадении ключа.
const tokenIssuer = "hy2xs-admin"
type MyClaims struct {
Admin bo.AccountBo `json:"admin"`
jwt.StandardClaims
jwt.RegisteredClaims
}
func GenToken(accountBo bo.AccountBo) (string, error) {
c := MyClaims{
Admin: accountBo,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),
Issuer: "hy2xs-admin",
},
}
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
if err != nil {
return "", errors.New(constant.SysError)
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
return token.SignedString([]byte(*config.Value))
}
func ParseToken(tokenString string) (*MyClaims, error) {
func jwtSecret() ([]byte, error) {
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
if err != nil {
return nil, errors.New(constant.SysError)
}
token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (i interface{}, err error) {
return []byte(*config.Value), nil
})
if err != nil {
return nil, errors.New(constant.IllegalTokenError)
if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
// Подпись пустым ключом дала бы токены, которые подделывает кто угодно.
// Раньше значение разыменовывалось без проверки: пустой JWT_SECRET
// молча превращался в ключ нулевой длины.
return nil, errors.New(constant.SysError)
}
if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
return claims, nil
}
return nil, errors.New(constant.TokenExpiredError)
return []byte(*config.Value), nil
}
func GetToken(c *gin.Context) string {
tokenStr := c.Request.Header.Get("Authorization")
if tokenStr == "" {
return ""
func GenToken(accountBo bo.AccountBo) (string, error) {
secret, err := jwtSecret()
if err != nil {
return "", err
}
return strings.SplitN(tokenStr, " ", 2)[1]
now := time.Now()
claims := MyClaims{
Admin: accountBo,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(TokenExpireDuration)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Issuer: tokenIssuer,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}
func ParseToken(tokenString string) (*MyClaims, error) {
secret, err := jwtSecret()
if err != nil {
return nil, err
}
token, err := jwt.ParseWithClaims(
tokenString,
&MyClaims{},
func(*jwt.Token) (interface{}, error) { return secret, nil },
// Допустимый алгоритм ровно один. Без этого ограничения набор
// алгоритмов задаёт сам токен, то есть недоверенный вход.
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(tokenIssuer),
// Токен без срока жизни не должен считаться валидным бессрочным.
jwt.WithExpirationRequired(),
)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, errors.New(constant.TokenExpiredError)
}
return nil, errors.New(constant.IllegalTokenError)
}
claims, ok := token.Claims.(*MyClaims)
if !ok || !token.Valid {
return nil, errors.New(constant.IllegalTokenError)
}
return claims, nil
}
// Функции GetToken здесь больше нет.
//
// Она доставала токен из заголовка Authorization вторым, независимым от
// middleware.JWTHandler способом — и делала это через
// strings.SplitN(tokenStr, " ", 2)[1], то есть паниковала на заголовке без
// пробела: `Authorization: abc` давал index out of range. Единственный
// потребитель — резервная ветка GetAdminInfo, которая была недостижима и
// проверяла меньше, чем middleware. Разбор токена у продукта ровно один.