Files
HY2XS_flamy/apps/cmd/server.go
T
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
2026-09-02 23:24:01 +05:00

175 lines
6.3 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")
}
// releaseResource закрывает то, чем владеет сам процесс админки.
//
// Вызова service.ReleaseHysteria2() здесь больше нет. Функция была пустой
// заглушкой `return nil` — остатком модели, в которой панель считала Hysteria
// своим подпроцессом и обязана была его отпустить. Жизненным циклом Hysteria
// владеет systemd, отпускать админке нечего, и шаг завершения, который ничего
// не делает, но выглядит освобождением ресурса, хуже отсутствующего.
func releaseResource() {
if err := dao.CloseSqliteDB(); 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
}