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 ") } 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 }