Files
HY2XS_flamy/apps/cmd/server.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

171 lines
5.7 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 cmd
import (
"context"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/middleware"
"hy2xs-admin/model/constant"
"hy2xs-admin/router"
"hy2xs-admin/service"
"hy2xs-admin/util"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
)
func resolveUiBindHost() (string, error) {
host := os.Getenv("HY2XS_UI_BIND_HOST")
if host == "" {
host = "127.0.0.1"
}
ip := net.ParseIP(host)
if ip == nil || ip.To4() == nil {
return "", errors.New("HY2XS_UI_BIND_HOST must be IPv4")
}
return host, nil
}
// resolveUiPort проверяет порт, полученный из контракта запуска.
//
// Источник истины — HY2XS_UI_PORT, который оркестратор подставляет в ExecStart
// как `-p`. Раньше это значение по дороге записывалось в SQLite и читалось
// оттуда обратно: круг, в котором база не добавляла ни одного факта, но делала
// вид, что порт принадлежит ей.
func resolveUiPort(port string) (int, error) {
trimmed := strings.TrimSpace(port)
if trimmed == "" {
return 0, errors.New("UI port is required: pass -p <port>")
}
value, err := strconv.Atoi(trimmed)
if err != nil || value < 1 || value > 65535 {
return 0, fmt.Errorf("port: %s is invalid", port)
}
return value, nil
}
func runServer(port string) error {
defer releaseResource()
middleware.InitLog()
uiPort, err := resolveUiPort(port)
if err != nil {
return err
}
bindHost, err := resolveUiBindHost()
if err != nil {
return err
}
if err := initFile(); err != nil {
return err
}
if err := dao.InitSql(); err != nil {
return err
}
if err := service.InitCron(); err != nil {
return err
}
// Порядок остановки обратный порядку регистрации: планировщик глушится
// РАНЬШЕ, чем releaseResource закроет SQLite. Каждая джоба ходит в базу,
// и обратный порядок означал бы гарантированные «database is closed» на
// каждом штатном завершении сервиса.
defer service.StopCron()
if err := service.InitHysteria2(); err != nil {
return err
}
// gin.New(), а не gin.Default().
//
// gin.Default() подключает gin.Logger(), который печатает в stdout путь
// ВМЕСТЕ с query string. Для админки это второй, независимый от logrus
// канал журналирования: stdout юнита уезжает в journald, а diagnostics
// кладёт `journalctl -u hy2xs-admin` в бандл. Пока он существовал,
// machine token из /internal/hysteria/auth?access_token=… утекал в
// журнал даже после того, как из собственного логгера админки query
// string убрали.
//
// HTTP-логгер у продукта ровно один — middleware.LogHandler, и он
// подключается пофазно в router.Router. Здесь остаётся только Recovery:
// паника в обработчике не должна ронять сервис.
r := gin.New()
r.Use(gin.Recovery())
router.Router(r)
// TLS внутри админки не поддерживается намеренно: панель слушает
// loopback и публикуется через SSH-туннель или reverse proxy, на котором
// TLS и заканчивается.
service.InitServer(fmt.Sprintf("%s:%d", bindHost, uiPort), r)
// Штатное завершение по сигналу.
//
// Раньше SIGTERM от systemd просто убивал процесс: соединения обрывались
// на середине, SQLite закрывался не через defer, а вместе с процессом, и
// джобы планировщика могли быть остановлены посреди записи. Для сервиса,
// который ведёт учёт трафика в транзакциях, это не абстракция — обычный
// `systemctl restart` попадал в это окно.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serverErr := make(chan error, 1)
go func() {
serverErr <- service.StartServer()
}()
select {
case err := <-serverErr:
return classifyServeError(err)
case <-ctx.Done():
logrus.Infof("shutdown signal received, stopping hy2xs-admin")
if err := service.StopServer(); err != nil {
return err
}
return classifyServeError(<-serverErr)
}
}
// classifyServeError отделяет штатное закрытие сервера от настоящего отказа.
func classifyServeError(err error) error {
if err == nil || errors.Is(err, http.ErrServerClosed) {
return nil
}
logrus.Errorf("start server err: %v", err)
return errors.New("start server err")
}
func releaseResource() {
if err := dao.CloseSqliteDB(); err != nil {
logrus.Errorf("%v", err)
}
if err := service.ReleaseHysteria2(); err != nil {
logrus.Errorf("%v", err)
}
}
func initFile() error {
var dirs = []string{
constant.LogDir,
constant.SqliteDBDir,
constant.BinDir,
filepath.Dir(constant.SqliteDBPath),
}
for _, item := range dirs {
if !util.Exists(item) {
if err := os.MkdirAll(item, 0o750); err != nil {
logrus.Errorf("%s create err: %v", item, err)
return errors.New(fmt.Sprintf("%s create err", item))
}
}
}
return nil
}