From b52fac1394d8ff545064aec3829d183b688d859b Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 27 Aug 2026 20:49:07 +0500 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D1=8B=20=D1=83=D1=82?= =?UTF-8?q?=D0=B5=D1=87=D0=BA=D0=B8=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B8=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20H=20UI=20=D0=B8=D0=B7=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экспорт в панели формировался через os.Create в /var/lib/hy2xs-admin/export, и файл там оставался навсегда. При includeSecrets=true это означало расшифрованные секреты пиров — фактические учётные данные доступа — в открытом виде на диске, накапливающиеся с каждым нажатием кнопки. Выгрузки формируются в памяти, каталога export/ больше нет. Generic export/import таблицы config удалён целиком. Он исключал только сырой Hysteria YAML, а в той же таблице лежат JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и HYSTERIA2_TRAFFIC_STATS_SECRET: кнопка Export выгружала их открытым текстом, импорт позволял подменить. Для PEER_SECRET_ENCRYPTION_KEY подмена ломает расшифровку секретов уже существующих пиров. Production-сценария у этой пары не было. Импорт пиров шёл мимо всей валидации, которую проходит обычное создание пира: в базу попадало имя любой длины и с любыми символами, disabled с произвольным числом, отрицательные счётчики. Файл применялся построчно, поэтому ошибка в середине оставляла список наполовину изменённым, а импорт мог перезаписать bootstrap-admin-peer, чей секрет продублирован в bootstrap-admin.secret. Партия проверяется целиком до первой записи, неизвестные поля отклоняются. Убран слой сетевых настроек панели: H_UI_WEB_PORT, H_UI_WEB_CONTEXT, H_UI_CRT_PATH, H_UI_KEY_PATH и собственный TLS. Оркестратор передавал порт аргументом, админка писала его в SQLite и тут же читала обратно, а UI показывал поля disabled — второй источник истины, из которого ничего нельзя было изменить. HUI_DATA/HUI_LOG заменены на HY2XS_DATA_DIR/HY2XS_LOG_DIR, база переименована в hy2xs-admin.db, reference-схема — в schema.sql. API namespace разделён по природе маршрутов: операторский API на /api, machine-auth Hysteria на /internal/hysteria/auth. Путь machine-auth — runtime-контракт, он уезжает в config.yaml и post-install.env, поэтому объявлен одной константой на компонент. Go-санитайзер экспорта вырезал секреты из URL только у ключей url/addr: будущее upstream-поле с другим именем уносило учётные данные и access_token целиком, а URL внутри списков не обрабатывались вовсе. Граница определяется значением, а не именем ключа — как в TS-санитайзере оркестратора. Заодно индикатор загрузки и цвета 401/404 переведены на брендовый токен: NProgress приходил со своим #29d и был единственным элементом вне палитры. --- apps/cmd/reset.go | 2 +- apps/cmd/server.go | 65 ++++-- apps/controller/config.go | 140 ++--------- apps/controller/peer.go | 63 +++-- apps/dao/config.go | 12 - apps/dao/sqlite.go | 18 +- apps/docs/sql/{h_ui_db.sql => schema.sql} | 0 apps/frontend/src/api/config/index.ts | 22 +- apps/frontend/src/lang/package/en.ts | 12 - apps/frontend/src/lang/package/ru.ts | 12 - apps/frontend/src/permission.ts | 2 + apps/frontend/src/styles/element-plus.scss | 9 +- apps/frontend/src/styles/nprogress.scss | 27 +++ apps/frontend/src/utils/request.ts | 7 +- apps/frontend/src/views/config/list/index.vue | 220 +----------------- apps/frontend/src/views/error-page/401.vue | 4 +- apps/frontend/src/views/error-page/404.vue | 4 +- apps/frontend/vite.config.ts | 3 +- apps/model/constant/api.go | 27 +++ apps/model/constant/config.go | 12 +- apps/model/constant/system.go | 21 +- apps/router/config.go | 6 +- apps/router/hysteria2.go | 11 +- apps/router/router.go | 48 ++-- apps/service/config.go | 36 --- apps/service/hysteria2_export.go | 33 ++- apps/service/hysteria2_export_test.go | 79 ++++++- apps/service/peer.go | 18 +- apps/service/peer_import.go | 129 ++++++++++ apps/service/peer_import_test.go | 161 +++++++++++++ apps/service/server.go | 40 +--- apps/util/export.go | 40 ---- package/systemd/hy2xs-admin.service | 2 - 33 files changed, 677 insertions(+), 608 deletions(-) rename apps/docs/sql/{h_ui_db.sql => schema.sql} (100%) create mode 100644 apps/frontend/src/styles/nprogress.scss create mode 100644 apps/model/constant/api.go create mode 100644 apps/service/peer_import.go create mode 100644 apps/service/peer_import_test.go delete mode 100644 apps/util/export.go diff --git a/apps/cmd/reset.go b/apps/cmd/reset.go index 2861b3d..875ef1c 100644 --- a/apps/cmd/reset.go +++ b/apps/cmd/reset.go @@ -32,7 +32,7 @@ func runReset(cmd *cobra.Command, args []string) { fmt.Println(err.Error()) os.Exit(1) } - if err = dao.InitSql(""); err != nil { + if err = dao.InitSql(); err != nil { fmt.Println(err.Error()) os.Exit(1) } diff --git a/apps/cmd/server.go b/apps/cmd/server.go index 15eb82f..6750153 100644 --- a/apps/cmd/server.go +++ b/apps/cmd/server.go @@ -15,6 +15,8 @@ import ( "net/http" "os" "path/filepath" + "strconv" + "strings" ) func resolveUiBindHost() (string, error) { @@ -29,31 +31,30 @@ func resolveUiBindHost() (string, error) { 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() - if err := initFile(); err != nil { - return err - } - if err := dao.InitSql(port); err != nil { - return err - } - if err := middleware.InitCron(); err != nil { - return err - } - if err := service.InitHysteria2(); err != nil { - return err - } - config, err := dao.GetConfig("key = ?", constant.HUIWebContext) - if err != nil { - return err - } - r := gin.Default() - router.Router(r, config.Value) - - serverPort, crtPath, keyPath, err := service.GetServerPortAndCert() + uiPort, err := resolveUiPort(port) if err != nil { return err } @@ -62,8 +63,27 @@ func runServer(port string) error { return err } - service.InitServer(fmt.Sprintf("%s:%d", bindHost, serverPort), r) - if err := service.StartServer(crtPath, keyPath); err != nil && err != http.ErrServerClosed { + if err := initFile(); err != nil { + return err + } + if err := dao.InitSql(); err != nil { + return err + } + if err := middleware.InitCron(); err != nil { + return err + } + if err := service.InitHysteria2(); err != nil { + return err + } + + r := gin.Default() + router.Router(r) + + // TLS внутри админки не поддерживается намеренно: панель слушает + // loopback и публикуется через SSH-туннель или reverse proxy, на котором + // TLS и заканчивается. + service.InitServer(fmt.Sprintf("%s:%d", bindHost, uiPort), r) + if err := service.StartServer(); err != nil && err != http.ErrServerClosed { logrus.Errorf("start server err: %v", err) return errors.New("start server err") } @@ -84,7 +104,6 @@ func initFile() error { constant.LogDir, constant.SqliteDBDir, constant.BinDir, - constant.ExportPathDir, filepath.Dir(constant.SqliteDBPath), } for _, item := range dirs { diff --git a/apps/controller/config.go b/apps/controller/config.go index 9397e57..7ecb93e 100644 --- a/apps/controller/config.go +++ b/apps/controller/config.go @@ -1,29 +1,24 @@ package controller import ( - "encoding/json" "fmt" "github.com/gin-gonic/gin" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" - "hy2xs-admin/model/entity" "hy2xs-admin/model/vo" "hy2xs-admin/service" - "hy2xs-admin/util" - "io" - "path/filepath" - "strconv" - "strings" "time" ) +// Ключи, которыми владеет install-оркестратор: панель обязана отказать в +// записи, а не молча создать второй источник истины. +// +// Сетевых настроек самой панели в этом списке больше нет, потому что их нет +// и в базе: порт и bind приходят из контракта запуска, TLS терминируется +// снаружи. См. model/constant/config.go. func isOrchestratorManagedConfigKey(key string) bool { switch key { - case constant.HUIWebPort, - constant.HUIWebContext, - constant.HUICrtPath, - constant.HUIKeyPath, - constant.Hysteria2Enable, + case constant.Hysteria2Enable, constant.Hysteria2Config, constant.Hysteria2TrafficStatsSecret: return true @@ -38,12 +33,6 @@ func UpdateConfigs(c *gin.Context) { return } - port, crtPath, keyPath, err := service.GetPortAndCert() - if err != nil { - vo.Fail(err.Error(), c) - return - } - needRestart := false for _, item := range configsUpdateDto.ConfigUpdateDtos { @@ -55,44 +44,6 @@ func UpdateConfigs(c *gin.Context) { return } - if key == constant.HUIWebPort && strconv.FormatInt(port, 10) != value { - port, err := strconv.Atoi(value) - if err != nil { - vo.Fail(fmt.Sprintf("port: %s is invalid", value), c) - return - } - if !util.IsPortAvailable(uint(port), "tcp") { - vo.Fail(fmt.Sprintf("port: %s is used", value), c) - return - } - needRestart = true - } - if key == constant.HUICrtPath && crtPath != value { - if value != "" && !util.Exists(value) { - vo.Fail(fmt.Sprintf("crt path: %s is not exist", value), c) - return - } - needRestart = true - } - if key == constant.HUIKeyPath && keyPath != value { - if value != "" && !util.Exists(value) { - vo.Fail(fmt.Sprintf("key path: %s is not exist", value), c) - return - } - needRestart = true - } - - if key == constant.HUIWebContext { - huiWebContext, err := service.GetConfig(constant.HUIWebContext) - if err != nil { - vo.Fail(err.Error(), c) - return - } - if *huiWebContext.Value != value { - needRestart = true - } - } - if key == constant.ResetTrafficCron { resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron) if err != nil { @@ -196,66 +147,17 @@ func ExportHysteria2Config(c *gin.Context) { c.Data(200, "application/octet-stream", sanitized) } -func ExportConfig(c *gin.Context) { - configs, err := service.ListConfigNotIn([]string{constant.Hysteria2Config}) - if err != nil { - vo.Fail(err.Error(), c) - return - } - fileName := fmt.Sprintf("SystemConfig-%s.json", time.Now().Format("20060102150405")) - filePath := filepath.Join(constant.ExportPathDir, fileName) - - if err = util.ExportFile(filePath, configs, 0); err != nil { - vo.Fail(err.Error(), c) - return - } - - if !util.Exists(filePath) { - vo.Fail("file not exist", c) - return - } - c.Header("Content-Type", "application/octet-stream") - c.Header("Content-Transfer-Encoding", "binary") - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) - c.File(filePath) -} - -func ImportConfig(c *gin.Context) { - file, header, err := c.Request.FormFile("file") - if err != nil { - vo.Fail(constant.SysError, c) - return - } - if header.Size > 1024*1024*2 { - vo.Fail("the file is too big", c) - return - } - if !strings.HasSuffix(header.Filename, ".json") { - vo.Fail(constant.InvalidError, c) - return - } - content, err := io.ReadAll(file) - if err != nil { - vo.Fail("json file read err", c) - return - } - var configs []entity.Config - if err = json.Unmarshal(content, &configs); err != nil { - vo.Fail("content Unmarshal err", c) - return - } - for _, cfg := range configs { - if cfg.Key != nil && isOrchestratorManagedConfigKey(*cfg.Key) { - vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", *cfg.Key), c) - return - } - } - if err = service.UpsertConfig(configs); err != nil { - vo.Fail(err.Error(), c) - return - } - go func() { - _ = service.StopServer() - }() - vo.Success(nil, c) -} +// Generic-выгрузки и загрузки таблицы `config` здесь нет намеренно. +// +// Она отдавала таблицу целиком, исключая только сырой Hysteria YAML, а в той +// же таблице лежат JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и +// HYSTERIA2_TRAFFIC_STATS_SECRET. Кнопка «Export» в панели выгружала их в +// открытом виде, а зеркальный импорт позволял их подменить — включая ключ +// шифрования, без которого перестают расшифровываться секреты уже +// существующих пиров. +// +// Осмысленного production-сценария у этой пары не было: конфигурацией сервера +// владеет install-оркестратор, перенос пиров делают ImportPeer/ExportPeer, а +// серверный конфиг Hysteria выгружается отдельным санитизирующим маршрутом. +// Поэтому маршруты удалены, а не оставлены с фильтром: список ключей, +// требующий ручного сопровождения, снова разошёлся бы со схемой базы. diff --git a/apps/controller/peer.go b/apps/controller/peer.go index 1f295f3..c0f363e 100644 --- a/apps/controller/peer.go +++ b/apps/controller/peer.go @@ -1,21 +1,21 @@ package controller import ( + "bytes" "encoding/json" "fmt" "io" - "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" "hy2xs-admin/model/bo" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/vo" "hy2xs-admin/service" - "hy2xs-admin/util" ) func resolveID(c *gin.Context) (int64, error) { @@ -129,30 +129,55 @@ func GetPeer(c *gin.Context) { vo.Success(peer, c) } +// peerImportMaxBytes — предел размера загружаемого файла. +const peerImportMaxBytes = 2 * 1024 * 1024 + +// ImportPeer принимает выгрузку, сделанную ExportPeer. +// +// Импорт — полноценная дверь в таблицу пиров, поэтому его вход проверяется +// так же строго, как обычное создание пира: разбор JSON ограничен по размеру, +// неизвестные поля отклоняются, а содержимое записей валидируется в +// service.ValidatePeerImportBatch до первой записи в базу. func ImportPeer(c *gin.Context) { file, header, err := c.Request.FormFile("file") if err != nil { vo.Fail(constant.SysError, c) return } - if header.Size > 1024*1024*2 { + defer func() { _ = file.Close() }() + + if header.Size > peerImportMaxBytes { vo.Fail("the file is too big", c) return } - if !strings.HasSuffix(header.Filename, ".json") { + if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") { vo.Fail(constant.InvalidError, c) return } - content, err := io.ReadAll(file) + + // Заявленный Size — это то, что сказал клиент; читаем с собственным + // пределом, чтобы расхождение не превращалось в чтение произвольного + // объёма в память. + content, err := io.ReadAll(io.LimitReader(file, peerImportMaxBytes+1)) if err != nil { vo.Fail("json file read err", c) return } + if len(content) > peerImportMaxBytes { + vo.Fail("the file is too big", c) + return + } + var peerExports []bo.PeerExport - if err = json.Unmarshal(content, &peerExports); err != nil { + decoder := json.NewDecoder(bytes.NewReader(content)) + // Опечатка в имени поля должна быть видимой ошибкой, а не молча + // импортированным значением по умолчанию. + decoder.DisallowUnknownFields() + if err = decoder.Decode(&peerExports); err != nil { vo.Fail("content Unmarshal err", c) return } + if err = service.UpsertPeerExport(peerExports); err != nil { vo.Fail(err.Error(), c) return @@ -160,6 +185,13 @@ func ImportPeer(c *gin.Context) { vo.Success(nil, c) } +// ExportPeer отдаёт выгрузку пиров прямо в ответ, не создавая файл на сервере. +// +// Раньше выгрузка шла через os.Create в /var/lib/hy2xs-admin/export и файл +// оставался там навсегда. При includeSecrets=true это означало расшифрованные +// секреты пиров — фактические учётные данные доступа — в открытом виде на +// диске, накапливающиеся с каждым нажатием кнопки. Артефакт, который покидает +// сервер, не должен существовать на сервере дольше самого запроса. func ExportPeer(c *gin.Context) { includeSecrets := strings.EqualFold(strings.TrimSpace(c.Query("includeSecrets")), "true") peerExports, err := service.ListExportPeer(includeSecrets) @@ -167,20 +199,19 @@ func ExportPeer(c *gin.Context) { vo.Fail(err.Error(), c) return } + + payload, err := json.MarshalIndent(peerExports, "", " ") + if err != nil { + logrus.Errorf("peer export marshal err: %v", err) + vo.Fail(constant.SysError, c) + return + } + fileName := fmt.Sprintf("PeerExport-%s.json", time.Now().Format("20060102150405")) - filePath := filepath.Join(constant.ExportPathDir, fileName) - if err = util.ExportFile(filePath, peerExports, 0); err != nil { - vo.Fail(err.Error(), c) - return - } - if !util.Exists(filePath) { - vo.Fail("file not exist", c) - return - } c.Header("Content-Type", "application/octet-stream") c.Header("Content-Transfer-Encoding", "binary") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) - c.File(filePath) + c.Data(200, "application/octet-stream", payload) } func ReleaseKickPeer(c *gin.Context) { diff --git a/apps/dao/config.go b/apps/dao/config.go index 98155c6..18533e7 100644 --- a/apps/dao/config.go +++ b/apps/dao/config.go @@ -4,7 +4,6 @@ import ( "errors" "github.com/sirupsen/logrus" "gorm.io/gorm" - "gorm.io/gorm/clause" "hy2xs-admin/model/constant" "hy2xs-admin/model/entity" "time" @@ -53,14 +52,3 @@ func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error) } return configs, nil } - -func UpsertConfig(configs []entity.Config) error { - if tx := sqliteDB.Model(&entity.Config{}).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "key"}}, - DoUpdates: clause.AssignmentColumns([]string{"value", "remark", "create_time", "update_time"}), - }).Create(configs); tx.Error != nil { - logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) - } - return nil -} diff --git a/apps/dao/sqlite.go b/apps/dao/sqlite.go index 1e68919..2f7b7e6 100644 --- a/apps/dao/sqlite.go +++ b/apps/dao/sqlite.go @@ -48,19 +48,18 @@ func InitSqliteDB() error { return nil } -func InitSql(port string) error { +// InitSql поднимает базу админки. +// +// Порт сюда больше не передаётся: раньше InitSql записывал в config тот самый +// порт, который получил аргументом из ExecStart, а startup тут же читал его +// обратно. База не была источником этого факта ни на одном шаге. +func InitSql() error { if err := InitSqliteDB(); err != nil { return err } if err := runMigrations(); err != nil { return err } - if port != "" { - if tx := sqliteDB.Exec("UPDATE config set value = ? where key = 'H_UI_WEB_PORT'", port); tx.Error != nil { - logrus.Errorf("sqlite exec err: %v", tx.Error) - return errors.New("sqlite exec err") - } - } if err := ensureSecureBootstrapAdmin(); err != nil { return err } @@ -307,11 +306,8 @@ func runMigrations() error { } func seedBaseConfig() error { + // Сетевых настроек панели здесь нет: ими владеет оркестратор. defaults := map[string]string{ - constant.HUIWebPort: "8080", - constant.HUIWebContext: "/", - constant.HUICrtPath: "", - constant.HUIKeyPath: "", constant.JwtSecret: "", constant.Hysteria2Enable: "0", constant.Hysteria2Config: "", diff --git a/apps/docs/sql/h_ui_db.sql b/apps/docs/sql/schema.sql similarity index 100% rename from apps/docs/sql/h_ui_db.sql rename to apps/docs/sql/schema.sql diff --git a/apps/frontend/src/api/config/index.ts b/apps/frontend/src/api/config/index.ts index b30f9d2..5200394 100644 --- a/apps/frontend/src/api/config/index.ts +++ b/apps/frontend/src/api/config/index.ts @@ -11,6 +11,9 @@ import { // Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку: // им владеет install-оркестратор. Клиентов записи, импорта, перезапуска и // загрузки сертификатов здесь нет — соответствующих маршрутов не существует. +// +// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и +// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer. export function getHysteria2ConfigApi(): AxiosPromise { return request({ @@ -43,25 +46,6 @@ export function updateConfigsApi(data: ConfigUpdateDto): AxiosPromise { }); } -export function exportConfigApi(): AxiosPromise { - return request({ - url: "/config/exportConfig", - method: "post", - responseType: "blob", - }); -} - -export function importConfigApi(data: FormData): AxiosPromise { - return request({ - url: "/config/importConfig", - method: "post", - headers: { - "Content-Type": "multipart/form-data", - }, - data: data, - }); -} - export function exportHysteria2ConfigApi(): AxiosPromise { return request({ url: "/config/exportHysteria2Config", diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 37a16ec..97824d1 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -97,8 +97,6 @@ export default { yes: "Yes", no: "No", securityRisk: "Security Risks", - defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. Click here to change`, - noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. Click here to enable`, required: "Required", warning: "Warning", fileFormatUnsupported: "File format not supported", @@ -180,25 +178,15 @@ export default { }, config: { orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure", - huiWebPort: "HY2XS admin Web Port", - huiWebContext: "HY2XS admin Web Context", hysteria2TrafficTime: "Hysteria2 Traffic Time", - huiCrtPath: "HY2XS admin CRT File Path", - huiKeyPath: "HY2XS admin KEY File Path", - useHysteria2Cert: "Use Hysteria2 cert", - huiHttps: "Open https on the panel", resetTrafficCron: "Reset traffic schedule task", resetTrafficCronTip: "Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3", resetTrafficMonth: "Run once a month, midnight, first of month", resetTrafficWeek: "Run once a week, midnight between Sat/Sun", - mustBeInteger: "Field must be an integer", - invalidWebContext: - "Field must start with / and contain only lowercase letters (a-z) and numbers (0-9)", invalidTrafficTime: "Field must be a number with up to one decimal place", }, monitor: { - huiVersion: "HY2XS admin Version", cpuPercent: "CPU Usage", memPercent: "Memory Usage", diskPercent: "Disk Usage", diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index 4a8cbc4..3bbb655 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -94,8 +94,6 @@ export default { yes: "Да", no: "Нет", securityRisk: "Риски безопасности", - defaultPassTip: `Смените пароль по умолчанию как можно скорее. Перейти к смене`, - noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. Открыть настройки`, required: "Обязательное поле", warning: "Внимание", fileFormatUnsupported: "Формат файла не поддерживается", @@ -176,25 +174,15 @@ export default { }, config: { orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure", - huiWebPort: "Порт HY2XS admin", - huiWebContext: "Web-контекст HY2XS admin", hysteria2TrafficTime: "Период учёта трафика Hysteria2", - huiCrtPath: "Путь к CRT HY2XS admin", - huiKeyPath: "Путь к KEY HY2XS admin", - useHysteria2Cert: "Использовать сертификат Hysteria2", - huiHttps: "Включить HTTPS панели", resetTrafficCron: "Расписание сброса трафика", resetTrafficCronTip: "Cron-выражение для планового сброса трафика", resetTrafficMonth: "Раз в месяц, в полночь первого дня", resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем", - mustBeInteger: "Поле должно быть целым числом", - invalidWebContext: - "Поле должно начинаться с / и содержать только строчные буквы (a-z) и цифры (0-9)", invalidTrafficTime: "Поле должно быть числом максимум с одним знаком после запятой", }, monitor: { - huiVersion: "Версия HY2XS admin", cpuPercent: "CPU", memPercent: "Память", diskPercent: "Диск", diff --git a/apps/frontend/src/permission.ts b/apps/frontend/src/permission.ts index ed058b4..8219847 100644 --- a/apps/frontend/src/permission.ts +++ b/apps/frontend/src/permission.ts @@ -3,7 +3,9 @@ import { useAdminStoreHook } from "@/store/modules/admin"; import { usePermissionStoreHook } from "@/store/modules/permission"; import NProgress from "nprogress"; +// Порядок важен: сначала vendor-база, затем тема HY2XS поверх неё. import "nprogress/nprogress.css"; +import "@/styles/nprogress.scss"; NProgress.configure({ showSpinner: false }); // Индикатор загрузки diff --git a/apps/frontend/src/styles/element-plus.scss b/apps/frontend/src/styles/element-plus.scss index 8a2cb28..788da79 100644 --- a/apps/frontend/src/styles/element-plus.scss +++ b/apps/frontend/src/styles/element-plus.scss @@ -26,9 +26,14 @@ box-sizing: content-box; } -// Цвет фона выбранной строки +// Цвет фона выбранной строки. +// +// Раньше здесь стоял литерал `#e1f3d8b5` — бледно-зелёный из исходного +// admin-шаблона, не имеющий отношения к палитре HY2XS. Подсветка выделения — +// это семантика «активный элемент», поэтому берётся самый светлый оттенок +// брендового primary. .el-table__body tr.current-row td { - background-color: #e1f3d8b5 !important; + background-color: var(--el-color-primary-light-9) !important; } // Единая высота header у card diff --git a/apps/frontend/src/styles/nprogress.scss b/apps/frontend/src/styles/nprogress.scss new file mode 100644 index 0000000..937717f --- /dev/null +++ b/apps/frontend/src/styles/nprogress.scss @@ -0,0 +1,27 @@ +// Тема NProgress. +// +// Vendor-стили nprogress приходят со своим фирменным синим `#29d`. Он никогда +// не переопределялся, поэтому индикатор загрузки был единственным элементом +// интерфейса вне брендовой палитры HY2XS. +// +// Здесь сознательно не пишется `#ff4e2f`: второй литерал того же цвета сразу +// стал бы вторым источником истины. Индикатор привязан к той же переменной, +// что и весь остальной интерфейс, и поменяется вместе с ней. +#nprogress { + .bar { + background: var(--el-color-primary); + } + + .peg { + box-shadow: + 0 0 10px var(--el-color-primary), + 0 0 5px var(--el-color-primary); + } + + // Spinner сейчас выключен через NProgress.configure({ showSpinner: false }), + // но правило оставлено: если его когда-нибудь включат, синий не вернётся. + .spinner-icon { + border-top-color: var(--el-color-primary); + border-left-color: var(--el-color-primary); + } +} diff --git a/apps/frontend/src/utils/request.ts b/apps/frontend/src/utils/request.ts index f731a2e..218f1b8 100644 --- a/apps/frontend/src/utils/request.ts +++ b/apps/frontend/src/utils/request.ts @@ -3,7 +3,12 @@ import { useAdminStoreHook } from "@/store/modules/admin"; import i18n from "@/lang/index"; const dynamicBase = (window as any).__dynamic_base__ || ""; -const API_BASE = "/hui"; +// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI: +// под ним лежали и machine-to-machine auth Hysteria, и JWT-защищённый +// админский API, хотя middleware у них разные. +// Значение синхронизировано с constant.AdminAPIBase в админке и +// ADMIN_API_BASE в оркестраторе. +const API_BASE = "/api"; const t = i18n.global.t; // Создание axios instance const service = axios.create({ diff --git a/apps/frontend/src/views/config/list/index.vue b/apps/frontend/src/views/config/list/index.vue index 6c18449..9465c8a 100644 --- a/apps/frontend/src/views/config/list/index.vue +++ b/apps/frontend/src/views/config/list/index.vue @@ -7,31 +7,6 @@ {{ $t("common.save") }} - - - - - {{ $t("common.import") }} - - - - - - - {{ $t("common.export") }} - - @@ -49,22 +24,6 @@ :model="dataForm" label-position="top" > - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/views/error-page/401.vue b/apps/frontend/src/views/error-page/401.vue index c6401d1..ca51fe8 100644 --- a/apps/frontend/src/views/error-page/401.vue +++ b/apps/frontend/src/views/error-page/401.vue @@ -61,7 +61,7 @@ function back() { .pan-back-btn { color: #fff; - background: #008489; + background: var(--el-color-primary); border: none !important; } @@ -90,7 +90,7 @@ function back() { } a { - color: #008489; + color: var(--el-color-primary); text-decoration: none; &:hover { diff --git a/apps/frontend/src/views/error-page/404.vue b/apps/frontend/src/views/error-page/404.vue index e648f5d..d596c4b 100644 --- a/apps/frontend/src/views/error-page/404.vue +++ b/apps/frontend/src/views/error-page/404.vue @@ -209,7 +209,7 @@ function message() { font-size: 32px; font-weight: bold; line-height: 40px; - color: #1482f0; + color: var(--el-color-primary); opacity: 0; animation-name: slideUp; animation-duration: 0.5s; @@ -251,7 +251,7 @@ function message() { color: #fff; text-align: center; cursor: pointer; - background: #1482f0; + background: var(--el-color-primary); border-radius: 100px; opacity: 0; animation-name: slideUp; diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 939fd90..afca3aa 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -21,7 +21,8 @@ const pathSrc = path.resolve(__dirname, "src"); const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g; const DRIVE_LETTER_REGEX = /^[a-z]:/i; const DEV_SERVER_PORT = 8080; -const API_BASE = "/hui"; +// Синхронизировано с src/utils/request.ts и constant.AdminAPIBase. +const API_BASE = "/api"; export default defineConfig((): UserConfig => { return { diff --git a/apps/model/constant/api.go b/apps/model/constant/api.go new file mode 100644 index 0000000..8de9210 --- /dev/null +++ b/apps/model/constant/api.go @@ -0,0 +1,27 @@ +package constant + +// Пространства имён HTTP API админки. +// +// Раньше всё жило под одним префиксом «hui» — наследие H UI, попавшее не +// только во внутренние имена, но и в runtime-контракт: путь machine-auth +// записывается в /etc/hysteria/config.yaml и в post-install.env, то есть +// является частью протокола между Hysteria и админкой. +// +// Разделение на два пространства отражает разницу в природе маршрутов: +// +// AdminAPIBase — операторский API, за JWT и rate limiter; +// HysteriaMachineAuth — не API интерфейса вовсе, а внутренний +// IPC-подобный endpoint между Hysteria и админкой, +// доступный только с loopback и по machine token. +// +// Значения обязаны совпадать с константами оркестратора +// (orchestrator/src/config/profile.ts). Сверка выполняется на сборке шагом +// verify_versions_contract. +const ( + // AdminAPIBase — префикс операторского и auth API панели. + AdminAPIBase = "/api" + + // HysteriaMachineAuthPath — полный путь machine-auth endpoint'а, + // который Hysteria вызывает при подключении пира. + HysteriaMachineAuthPath = "/internal/hysteria/auth" +) diff --git a/apps/model/constant/config.go b/apps/model/constant/config.go index 0b20276..c39a49f 100644 --- a/apps/model/constant/config.go +++ b/apps/model/constant/config.go @@ -1,10 +1,14 @@ package constant +// Ключи таблицы `config` — то, чем действительно владеет админка. +// +// Сетевых настроек самой панели (порт, web-контекст, пути к сертификатам) +// здесь больше нет. Они достались от H UI, где панель конфигурировала себя +// сама, и в архитектуре HY2XS были вторым источником истины: порт приходит из +// HY2XS_UI_PORT через systemd, bind — из HY2XS_UI_BIND_HOST, TLS +// терминируется на внешнем слое. Панель записывала в SQLite тот же порт, +// который ей передали аргументом, и тут же читала его обратно. const ( - HUIWebPort = "H_UI_WEB_PORT" - HUIWebContext = "H_UI_WEB_CONTEXT" - HUICrtPath = "H_UI_CRT_PATH" - HUIKeyPath = "H_UI_KEY_PATH" JwtSecret = "JWT_SECRET" PeerSecretKey = "PEER_SECRET_KEY" PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY" diff --git a/apps/model/constant/system.go b/apps/model/constant/system.go index e7be77e..2ce70f4 100644 --- a/apps/model/constant/system.go +++ b/apps/model/constant/system.go @@ -13,14 +13,23 @@ func getenv(name string, fallback string) string { return value } +// Пути читаются напрямую из canonical env HY2XS. +// +// Раньше здесь стояли HUI_DATA/HUI_LOG, а systemd-юнит перекладывал в них +// значения из HY2XS_DATA_DIR/HY2XS_LOG_DIR. Этот мост от старого H UI не давал +// ничего, кроме второго имени у той же величины, и делал юнит обязательным +// участником контракта конфигурации. var ( - DataRootDir = getenv("HUI_DATA", "/var/lib/hy2xs-admin") - LogDir = getenv("HUI_LOG", "/var/log/hy2xs") - SqliteDBDir = DataRootDir - BinDir = filepath.Join(DataRootDir, "bin") - ExportPathDir = filepath.Join(DataRootDir, "export") + DataRootDir = getenv("HY2XS_DATA_DIR", "/var/lib/hy2xs-admin") + LogDir = getenv("HY2XS_LOG_DIR", "/var/log/hy2xs") + SqliteDBDir = DataRootDir + BinDir = filepath.Join(DataRootDir, "bin") - SqliteDBPath = filepath.Join(DataRootDir, "h_ui.db") + // Каталога для выгрузок здесь нет намеренно: артефакты экспорта + // формируются в памяти и отдаются прямо в ответ. Персистентный + // export-каталог накапливал на диске JSON с секретами. + + SqliteDBPath = filepath.Join(DataRootDir, "hy2xs-admin.db") Hysteria2ConfigPath = "/etc/hysteria/config.yaml" Hysteria2BinPath = "/usr/local/bin/hysteria" diff --git a/apps/router/config.go b/apps/router/config.go index a7488ff..196b171 100644 --- a/apps/router/config.go +++ b/apps/router/config.go @@ -10,6 +10,10 @@ import ( // importHysteria2Config), перезапуска панели и загрузки сертификатов удалены, // а не оставлены заглушками: маршрут, который всегда отвечает «feature // disabled», вводит в заблуждение и остаётся точкой входа. +// +// По той же причине здесь нет generic exportConfig/importConfig: они отдавали +// и принимали таблицу `config` целиком, вместе с JWT- и peer-ключами. +// См. комментарий в controller/config.go. func initConfigRouter(configApi *gin.RouterGroup) { config := configApi.Group("/config") { @@ -18,7 +22,5 @@ func initConfigRouter(configApi *gin.RouterGroup) { config.POST("/listConfig", controller.ListConfig) config.GET("/getHysteria2Config", controller.GetHysteria2Config) config.POST("/exportHysteria2Config", controller.ExportHysteria2Config) - config.POST("/exportConfig", controller.ExportConfig) - config.POST("/importConfig", controller.ImportConfig) } } diff --git a/apps/router/hysteria2.go b/apps/router/hysteria2.go index b99a6ea..4926e65 100644 --- a/apps/router/hysteria2.go +++ b/apps/router/hysteria2.go @@ -9,9 +9,10 @@ import ( // и перезапуск выполняются install-оркестратором. Маршрутов updater'а здесь // нет намеренно — API-контракт не должен обещать операцию, которой у продукта // принципиально не существует, и не должен нести лишнюю attack surface. -func initHysteria2MachineAuthRouter(hysteria2Api *gin.RouterGroup) { - hysteria2 := hysteria2Api.Group("/hysteria2") - { - hysteria2.POST("/auth", controller.Hysteria2Auth) - } +// +// Единственный маршрут этой группы — machine-auth. Группа уже смонтирована на +// constant.HysteriaMachineAuthPath, поэтому относительный путь здесь пустой: +// полный путь объявлен ровно в одном месте. +func initHysteria2MachineAuthRouter(machineAuthAPI *gin.RouterGroup) { + machineAuthAPI.POST("", controller.Hysteria2Auth) } diff --git a/apps/router/router.go b/apps/router/router.go index 6c0efb0..21436a6 100644 --- a/apps/router/router.go +++ b/apps/router/router.go @@ -7,10 +7,16 @@ import ( "hy2xs-admin/middleware" "hy2xs-admin/model/constant" "os" - "strings" ) -func Router(router *gin.Engine, huiWebContext *string) { +// Router собирает HTTP-контур админки. +// +// Панель всегда живёт в корне: настраиваемый web-контекст был возможностью H UI +// разворачивать панель по произвольному пути, а HY2XS слушает только loopback +// и отдаёт панель через SSH-туннель или reverse proxy. Настройка, которую UI +// уже не позволял менять, а backend продолжал читать из SQLite, — это не +// точка расширения, а лишний источник истины. +func Router(router *gin.Engine) { router.GET("/healthz", func(c *gin.Context) { sqliteReady := dao.IsSqliteReady() configReadable := false @@ -36,34 +42,32 @@ func Router(router *gin.Engine, huiWebContext *string) { }) }) - relativePath := "/" - if huiWebContext != nil && strings.HasPrefix(*huiWebContext, "/") { - relativePath = *huiWebContext - } + frontend.InitFrontend(router, "/") - frontend.InitFrontend(router, relativePath) + // Machine-контур. Не часть операторского API: это внутренний канал между + // Hysteria и админкой, поэтому у него отдельное пространство имён, + // собственный middleware и никакого JWT. + machineAPI := router.Group(constant.HysteriaMachineAuthPath) + machineAPI.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler()) + initHysteria2MachineAuthRouter(machineAPI) - globalGroup := router.Group(relativePath) + api := router.Group(constant.AdminAPIBase) - machineApi := globalGroup.Group("/hui") - machineApi.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler()) - initHysteria2MachineAuthRouter(machineApi) + authAPI := api.Group("") + authAPI.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler()) + initAuthRouter(authAPI) - authApi := globalGroup.Group("/hui") - authApi.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler()) - initAuthRouter(authApi) - - huiAdminApi := globalGroup.Group("/hui") - huiAdminApi.Use( + adminAPI := api.Group("") + adminAPI.Use( middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler(), middleware.JWTHandler(), middleware.AdminHandler(), ) - initAdminRouter(huiAdminApi) - initDashboardRouter(huiAdminApi) - initPeerRouter(huiAdminApi) - initConfigRouter(huiAdminApi) - initLogRouter(huiAdminApi) + initAdminRouter(adminAPI) + initDashboardRouter(adminAPI) + initPeerRouter(adminAPI) + initConfigRouter(adminAPI) + initLogRouter(adminAPI) } diff --git a/apps/service/config.go b/apps/service/config.go index fbcc801..737f93a 100644 --- a/apps/service/config.go +++ b/apps/service/config.go @@ -27,10 +27,6 @@ func ListConfig(keys []string) ([]entity.Config, error) { return dao.ListConfig("key in ?", keys) } -func ListConfigNotIn(keys []string) ([]entity.Config, error) { - return dao.ListConfig("key not in ?", keys) -} - func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) { var serverConfig bo.Hysteria2ServerConfig config, err := dao.GetConfig("key = ?", constant.Hysteria2Config) @@ -53,10 +49,6 @@ func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) { return serverConfig, nil } -func UpsertConfig(configs []entity.Config) error { - return dao.UpsertConfig(configs) -} - func GetHysteria2ApiPort() (int64, error) { hysteria2Config, err := GetHysteria2Config() if err != nil { @@ -100,31 +92,3 @@ func parseTrafficStatsPort(listen string) (int64, error) { } return port, nil } - -func GetPortAndCert() (int64, string, string, error) { - configs, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.HUICrtPath, constant.HUIKeyPath}) - if err != nil { - return 0, "", "", err - } - port := "" - crtPath := "" - keyPath := "" - for _, config := range configs { - value := *config.Value - if *config.Key == constant.HUIWebPort { - port = value - } else if *config.Key == constant.HUICrtPath { - crtPath = value - } else if *config.Key == constant.HUIKeyPath { - keyPath = value - } - } - - portInt, err := strconv.ParseInt(port, 10, 64) - if err != nil { - logrus.Errorf("port: %s is invalid", port) - return 0, "", "", errors.New(fmt.Sprintf("port: %s is invalid", port)) - } - - return portInt, crtPath, keyPath, nil -} diff --git a/apps/service/hysteria2_export.go b/apps/service/hysteria2_export.go index ad82130..a7042a5 100644 --- a/apps/service/hysteria2_export.go +++ b/apps/service/hysteria2_export.go @@ -154,20 +154,35 @@ func redactNode(node *yaml.Node, path []string) { redactSubtree(value) continue } - if value.Kind == yaml.ScalarNode && looksLikeURLKey(key) { - value.Value = sanitizeURLValue(value.Value) - value.Tag = "!!str" - value.Style = 0 - continue - } redactNode(value, childPath) } + + case yaml.ScalarNode: + // Секрет может лежать в URL и без «говорящего» имени ключа. + // + // Раньше здесь стоял фильтр по имени ключа (url / addr / *url), и + // upstream-поле вроде `endpoint: https://user:pass@host/?token=…` + // уезжало в выгрузку целиком. Правильная граница — не имя ключа, а + // сам факт, что значение разбирается как URL: sanitizeURLValue + // возвращает вход без изменений, если это не URL. Ровно так же + // устроен TS-санитайзер в orchestrator/src/lib/redaction.ts. + sanitizeURLScalar(node) } } -func looksLikeURLKey(key string) bool { - lowered := strings.ToLower(key) - return lowered == "url" || lowered == "addr" || strings.HasSuffix(lowered, "url") +// sanitizeURLScalar применяет URL-санитайзер к скаляру, не трогая ни его тип, +// ни значения, которые URL не являются. +func sanitizeURLScalar(node *yaml.Node) { + if node.Tag != "" && node.Tag != "!!str" { + return + } + sanitized := sanitizeURLValue(node.Value) + if sanitized == node.Value { + return + } + node.Value = sanitized + node.Tag = "!!str" + node.Style = 0 } func redactSubtree(node *yaml.Node) { diff --git a/apps/service/hysteria2_export_test.go b/apps/service/hysteria2_export_test.go index b49565d..0c13640 100644 --- a/apps/service/hysteria2_export_test.go +++ b/apps/service/hysteria2_export_test.go @@ -26,7 +26,7 @@ acme: auth: type: http http: - url: http://127.0.0.1:8080/hui/hysteria2/auth?access_token=machine-secret + url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=machine-secret insecure: false userpass: alice: alice-password @@ -71,6 +71,11 @@ someFutureUpstreamFeature: list: - a - b + # Ни одно из имён ниже не является ни secret-shaped, ни url/addr-подобным: + # именно так выглядит поле, которое upstream добавит завтра. + endpoint: https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token + mirrors: + - https://mirror-user:mirror-p4ss@mirror.example.com/pull ` func sanitizeForTest(t *testing.T, raw string) string { @@ -93,6 +98,9 @@ func TestSanitizeHysteria2ConfigYaml_RemovesSecrets(t *testing.T) { "bob-password", "proxy-password", "super-secret-token", + "svc-p4ss", + "endpoint-token", + "mirror-p4ss", } for _, secret := range leaked { if strings.Contains(sanitized, secret) { @@ -152,7 +160,7 @@ func TestSanitizeHysteria2ConfigYaml_KeepsNonSecretOperationalFields(t *testing. func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testing.T) { sanitized := sanitizeForTest(t, exportSampleConfig) - if !strings.Contains(sanitized, "127.0.0.1:8080/hui/hysteria2/auth") { + if !strings.Contains(sanitized, "127.0.0.1:8080/internal/hysteria/auth") { t.Fatalf("auth url shape was lost:\n%s", sanitized) } if !strings.Contains(sanitized, "access_token="+RedactedPlaceholder) && @@ -161,6 +169,69 @@ func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testin } } +// Регрессия: санитайзер применял sanitizeURLValue только к ключам url/addr/*url. +// Любое будущее upstream-поле с другим именем уносило встроенные учётные данные +// и секретные query-параметры наружу целиком. TS-санитайзер оркестратора такой +// границы никогда не имел, и расхождение между двумя реализациями одного +// контракта — само по себе дефект. +func TestSanitizeHysteria2ConfigYaml_RedactsURLsUnderArbitraryKeys(t *testing.T) { + sanitized := sanitizeForTest(t, exportSampleConfig) + + var parsed map[string]any + if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil { + t.Fatalf("sanitized output is not valid yaml: %v", err) + } + future, ok := parsed["someFutureUpstreamFeature"].(map[string]any) + if !ok { + t.Fatalf("unknown upstream section was dropped:\n%s", sanitized) + } + + endpoint, _ := future["endpoint"].(string) + if !strings.Contains(endpoint, "relay.example.com") { + t.Fatalf("endpoint потерял адрес, диагностика станет бесполезной: %q", endpoint) + } + if strings.Contains(endpoint, "svc-p4ss") || strings.Contains(endpoint, "endpoint-token") { + t.Fatalf("endpoint унёс секреты наружу: %q", endpoint) + } + + // Скаляры внутри последовательностей раньше вообще не доходили до + // санитайзера: у redactNode не было ветки ScalarNode. + mirrors, ok := future["mirrors"].([]any) + if !ok || len(mirrors) != 1 { + t.Fatalf("список mirrors потерян: %+v", future["mirrors"]) + } + mirror, _ := mirrors[0].(string) + if !strings.Contains(mirror, "mirror.example.com") { + t.Fatalf("mirror потерял адрес: %q", mirror) + } + if strings.Contains(mirror, "mirror-p4ss") { + t.Fatalf("mirror унёс встроенные учётные данные: %q", mirror) + } +} + +// Санитайзер обязан оставаться безвредным для значений, которые URL не +// являются: он проходит по каждому скаляру документа. +func TestSanitizeHysteria2ConfigYaml_LeavesNonURLScalarsIntact(t *testing.T) { + sanitized := sanitizeForTest(t, exportSampleConfig) + + intact := []string{ + "up: 50 mbps", + "down: 50 mbps", + "listen: 0.0.0.0:443", + "listen: 127.0.0.1:36712", + "addr: 10.0.0.1:1080", + "email: admin@example.com", + "dir: /var/lib/hysteria/acme", + "initStreamReceiveWindow: 8388608", + "tuning: 42", + } + for _, fragment := range intact { + if !strings.Contains(sanitized, fragment) { + t.Fatalf("санитайзер изменил не-URL значение %q:\n%s", fragment, sanitized) + } + } +} + func TestSanitizeURLValue(t *testing.T) { tests := []struct { name string @@ -170,8 +241,8 @@ func TestSanitizeURLValue(t *testing.T) { }{ { name: "strips access token", - in: "http://127.0.0.1:8080/hui/hysteria2/auth?access_token=abc123", - mustKeep: []string{"127.0.0.1:8080", "/hui/hysteria2/auth"}, + in: "http://127.0.0.1:8080/internal/hysteria/auth?access_token=abc123", + mustKeep: []string{"127.0.0.1:8080", "/internal/hysteria/auth"}, mustRemove: []string{"abc123"}, }, { diff --git a/apps/service/peer.go b/apps/service/peer.go index d255af9..12f2abf 100644 --- a/apps/service/peer.go +++ b/apps/service/peer.go @@ -205,11 +205,15 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) { } func UpsertPeerExport(items []bo.PeerExport) error { + // Первый проход — только проверка. Импорт либо применяется целиком, либо + // не применяется вовсе: наполовину импортированный список пиров хуже + // отклонённого файла. + if err := ValidatePeerImportBatch(items); err != nil { + return err + } + for _, item := range items { name := strings.TrimSpace(item.Name) - if name == "" { - return errors.New(constant.InvalidError) - } var existing entity.Peer var err error @@ -217,10 +221,16 @@ func UpsertPeerExport(items []bo.PeerExport) error { if authID != "" { existing, err = dao.GetPeer("auth_id = ?", authID) } - if (err != nil || existing.Id == nil) && name != "" { + if err != nil || existing.Id == nil { existing, err = dao.GetPeer("name = ?", name) } + // Пир установщика не переопределяется импортом ни при каком совпадении: + // его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret. + if err == nil && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName { + return fmt.Errorf("peer import: пир %q принадлежит установщику и не может быть изменён импортом", ReservedBootstrapPeerName) + } + quota := item.QuotaBytes expires := item.ExpiresAt maxDevices := item.MaxDevices diff --git a/apps/service/peer_import.go b/apps/service/peer_import.go new file mode 100644 index 0000000..9e32e51 --- /dev/null +++ b/apps/service/peer_import.go @@ -0,0 +1,129 @@ +package service + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "hy2xs-admin/model/bo" +) + +// Проверка импортируемой выгрузки пиров. +// +// Обычный путь создания пира проходит через dto.PeerSaveDto и его теги +// валидатора: имя 6-32 символа из ограниченного набора, quota >= -1, +// maxDevices >= 1, disabled строго 0/1 и так далее. Импорт JSON шёл мимо всего +// этого и писал в базу что угодно — включая disabled=7 и имя с переводом +// строки, которое потом попадало бы в клиентскую ссылку. +// +// Правила здесь намеренно повторяют PeerSaveDto: две двери в одну и ту же +// таблицу не имеют права требовать разного. + +// MaxPeerImportItems ограничивает размер одной операции импорта. +// Верхняя граница нужна не для памяти (файл уже ограничен 2 МБ), а чтобы +// одна ошибка в файле не превращалась в многоминутную операцию с AES на +// каждой записи. +const MaxPeerImportItems = 5000 + +// ReservedBootstrapPeerName — пир, который создаёт установка из +// HY2XS_ADMIN_CON_PASS. Его секрет продублирован в +// /etc/hy2xs/bootstrap-admin.secret, и подмена секрета импортом молча +// рассинхронизировала бы файл на диске с базой. +const ReservedBootstrapPeerName = "bootstrap-admin-peer" + +// Тот же набор символов, что и у validateStr в слое контроллеров. +var peerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$`) + +// authId генерируется через util.RandomString и участвует в HTTP-обмене с +// Hysteria, поэтому здесь набор ещё уже. +var peerAuthIDPattern = regexp.MustCompile(`^[a-zA-Z0-9._\-]{1,64}$`) + +func peerImportError(index int, reason string) error { + return fmt.Errorf("peer import: запись #%d: %s", index+1, reason) +} + +// ValidatePeerImportBatch проверяет всю партию целиком и не меняет состояние. +// +// Импорт применяется двумя проходами: сначала проверка всех записей, затем +// запись. Иначе файл, у которого невалидна десятая запись, оставлял бы первые +// девять уже применёнными — оператор получал бы ошибку и наполовину изменённый +// список пиров. +func ValidatePeerImportBatch(items []bo.PeerExport) error { + if len(items) == 0 { + return errors.New("peer import: файл не содержит ни одной записи") + } + if len(items) > MaxPeerImportItems { + return fmt.Errorf("peer import: слишком много записей: %d (максимум %d)", len(items), MaxPeerImportItems) + } + + seenNames := make(map[string]int, len(items)) + seenAuthIDs := make(map[string]int, len(items)) + + for i, item := range items { + name := strings.TrimSpace(item.Name) + if name == "" { + return peerImportError(i, "пустое имя") + } + if !peerNamePattern.MatchString(name) { + return peerImportError(i, fmt.Sprintf("недопустимое имя %q: 6-32 символа из [a-zA-Z0-9!@#$%%^&*()_+-=]", name)) + } + if name == ReservedBootstrapPeerName { + return peerImportError(i, fmt.Sprintf("имя %q зарезервировано установщиком и не может быть импортировано", name)) + } + if prev, ok := seenNames[name]; ok { + return peerImportError(i, fmt.Sprintf("имя %q дублирует запись #%d", name, prev+1)) + } + seenNames[name] = i + + authID := strings.TrimSpace(item.AuthId) + if authID != "" { + if !peerAuthIDPattern.MatchString(authID) { + return peerImportError(i, fmt.Sprintf("недопустимый authId %q", authID)) + } + if authID == ReservedBootstrapPeerName { + return peerImportError(i, fmt.Sprintf("authId %q зарезервирован установщиком", authID)) + } + if prev, ok := seenAuthIDs[authID]; ok { + return peerImportError(i, fmt.Sprintf("authId %q дублирует запись #%d", authID, prev+1)) + } + seenAuthIDs[authID] = i + } + + if secret := strings.TrimSpace(item.Secret); secret != "" { + if len(secret) < 6 || len(secret) > 128 { + return peerImportError(i, "длина secret должна быть 6-128 символов") + } + } + + if len([]rune(item.Remark)) > 64 { + return peerImportError(i, "remark длиннее 64 символов") + } + + if item.QuotaBytes < -1 { + return peerImportError(i, fmt.Sprintf("quotaBytes должен быть >= -1, получено %d", item.QuotaBytes)) + } + if item.ExpiresAt < 0 { + return peerImportError(i, fmt.Sprintf("expiresAt должен быть >= 0, получено %d", item.ExpiresAt)) + } + // Ноль означает «не задано»: UpsertPeerExport подставит значение по + // умолчанию. Отрицательное значение — это уже ошибка в файле. + if item.MaxDevices < 0 { + return peerImportError(i, fmt.Sprintf("maxDevices должен быть >= 0, получено %d", item.MaxDevices)) + } + if item.Disabled != 0 && item.Disabled != 1 { + return peerImportError(i, fmt.Sprintf("disabled должен быть 0 или 1, получено %d", item.Disabled)) + } + if item.BannedUntil < 0 { + return peerImportError(i, fmt.Sprintf("bannedUntil должен быть >= 0, получено %d", item.BannedUntil)) + } + if item.DownloadBytes < 0 || item.UploadBytes < 0 { + return peerImportError(i, "счётчики трафика не могут быть отрицательными") + } + if item.LastConnectionAt < 0 { + return peerImportError(i, fmt.Sprintf("lastConnectionAt должен быть >= 0, получено %d", item.LastConnectionAt)) + } + } + + return nil +} diff --git a/apps/service/peer_import_test.go b/apps/service/peer_import_test.go new file mode 100644 index 0000000..4562f9b --- /dev/null +++ b/apps/service/peer_import_test.go @@ -0,0 +1,161 @@ +package service + +import ( + "strings" + "testing" + + "hy2xs-admin/model/bo" +) + +func validPeer(name string) bo.PeerExport { + return bo.PeerExport{ + Name: name, + Remark: "office laptop", + QuotaBytes: -1, + ExpiresAt: 0, + MaxDevices: 3, + Disabled: 0, + } +} + +func TestValidatePeerImportBatchAcceptsExportShape(t *testing.T) { + items := []bo.PeerExport{ + validPeer("alpha1"), + { + Name: "bravo-2", + AuthId: "Kf83jd0sLa93kd0sQx", + Secret: "s3cret-value", + QuotaBytes: 107374182400, + DownloadBytes: 12, + UploadBytes: 34, + ExpiresAt: 1893456000000, + MaxDevices: 5, + Disabled: 1, + BannedUntil: 0, + LastConnectionAt: 1893456000000, + }, + } + if err := ValidatePeerImportBatch(items); err != nil { + t.Fatalf("валидная выгрузка отклонена: %v", err) + } +} + +func TestValidatePeerImportBatchRejectsEmpty(t *testing.T) { + if err := ValidatePeerImportBatch(nil); err == nil { + t.Fatal("пустой импорт должен быть отклонён") + } +} + +// Регрессия: импорт шёл мимо dto.PeerSaveDto, поэтому в базу попадало имя +// любой длины и с любыми символами — включая перевод строки, который потом +// оказался бы в клиентской ссылке. +func TestValidatePeerImportBatchRejectsBadNames(t *testing.T) { + cases := map[string]string{ + "пустое": "", + "только пробелы": " ", + "короткое": "abc", + "слишком длинное": strings.Repeat("a", 33), + "перевод строки": "peer\nname", + "пробел внутри": "peer name", + "недопустимый слэш": "peer/name", + } + for label, name := range cases { + item := validPeer(name) + if err := ValidatePeerImportBatch([]bo.PeerExport{item}); err == nil { + t.Errorf("%s: имя %q принято, ожидался отказ", label, name) + } + } +} + +// Пир установщика продублирован в /etc/hy2xs/bootstrap-admin.secret: +// подмена его секрета импортом рассинхронизировала бы файл с базой. +func TestValidatePeerImportBatchRejectsReservedBootstrapPeer(t *testing.T) { + item := validPeer(ReservedBootstrapPeerName) + err := ValidatePeerImportBatch([]bo.PeerExport{item}) + if err == nil { + t.Fatal("зарезервированное имя bootstrap-пира принято") + } + if !strings.Contains(err.Error(), ReservedBootstrapPeerName) { + t.Fatalf("сообщение не называет зарезервированное имя: %v", err) + } + + byAuthID := validPeer("alpha1") + byAuthID.AuthId = ReservedBootstrapPeerName + if err := ValidatePeerImportBatch([]bo.PeerExport{byAuthID}); err == nil { + t.Fatal("зарезервированный authId принят") + } +} + +func TestValidatePeerImportBatchRejectsOutOfRangeNumbers(t *testing.T) { + cases := map[string]func(*bo.PeerExport){ + "quota < -1": func(p *bo.PeerExport) { p.QuotaBytes = -2 }, + "expiresAt < 0": func(p *bo.PeerExport) { p.ExpiresAt = -1 }, + "maxDevices < 0": func(p *bo.PeerExport) { p.MaxDevices = -1 }, + "disabled = 7": func(p *bo.PeerExport) { p.Disabled = 7 }, + "bannedUntil < 0": func(p *bo.PeerExport) { p.BannedUntil = -1 }, + "download < 0": func(p *bo.PeerExport) { p.DownloadBytes = -1 }, + "upload < 0": func(p *bo.PeerExport) { p.UploadBytes = -1 }, + "lastConnection < 0": func(p *bo.PeerExport) { p.LastConnectionAt = -1 }, + "remark длиннее 64": func(p *bo.PeerExport) { p.Remark = strings.Repeat("я", 65) }, + "secret короче 6": func(p *bo.PeerExport) { p.Secret = "abc" }, + "secret длиннее 128": func(p *bo.PeerExport) { p.Secret = strings.Repeat("s", 129) }, + "authId с пробелом": func(p *bo.PeerExport) { p.AuthId = "bad id" }, + "authId длиннее 64": func(p *bo.PeerExport) { p.AuthId = strings.Repeat("a", 65) }, + } + for label, mutate := range cases { + item := validPeer("alpha1") + mutate(&item) + if err := ValidatePeerImportBatch([]bo.PeerExport{item}); err == nil { + t.Errorf("%s: запись принята, ожидался отказ", label) + } + } +} + +// -1 (безлимит) и 0 (не задано) — легальные значения, а не пограничный мусор. +func TestValidatePeerImportBatchAcceptsSentinelValues(t *testing.T) { + item := validPeer("alpha1") + item.QuotaBytes = -1 + item.MaxDevices = 0 + item.ExpiresAt = 0 + if err := ValidatePeerImportBatch([]bo.PeerExport{item}); err != nil { + t.Fatalf("допустимые sentinel-значения отклонены: %v", err) + } +} + +func TestValidatePeerImportBatchRejectsDuplicates(t *testing.T) { + dupName := []bo.PeerExport{validPeer("alpha1"), validPeer("alpha1")} + if err := ValidatePeerImportBatch(dupName); err == nil { + t.Error("дублирующееся имя принято") + } + + first := validPeer("alpha1") + first.AuthId = "same-auth-id" + second := validPeer("bravo1") + second.AuthId = "same-auth-id" + if err := ValidatePeerImportBatch([]bo.PeerExport{first, second}); err == nil { + t.Error("дублирующийся authId принят") + } +} + +func TestValidatePeerImportBatchRejectsOversizedBatch(t *testing.T) { + items := make([]bo.PeerExport, MaxPeerImportItems+1) + for i := range items { + items[i] = validPeer("alpha1") + } + if err := ValidatePeerImportBatch(items); err == nil { + t.Fatal("партия больше лимита принята") + } +} + +// Валидация обязана быть полной до первой записи в базу: файл, у которого +// невалидна последняя запись, не должен применить первые. +func TestValidatePeerImportBatchReportsLastInvalidRecord(t *testing.T) { + items := []bo.PeerExport{validPeer("alpha1"), validPeer("bravo1"), validPeer("x")} + err := ValidatePeerImportBatch(items) + if err == nil { + t.Fatal("невалидная последняя запись пропущена") + } + if !strings.Contains(err.Error(), "#3") { + t.Fatalf("сообщение не указывает номер записи: %v", err) + } +} diff --git a/apps/service/server.go b/apps/service/server.go index adfe3ad..5649df4 100644 --- a/apps/service/server.go +++ b/apps/service/server.go @@ -3,9 +3,7 @@ package service import ( "context" "errors" - "fmt" "github.com/sirupsen/logrus" - "hy2xs-admin/util" "net/http" "time" ) @@ -19,10 +17,13 @@ func InitServer(addr string, handler http.Handler) { } } -func StartServer(crtPath string, keyPath string) error { - if crtPath != "" && keyPath != "" { - return server.ListenAndServeTLS(crtPath, keyPath) - } +// StartServer запускает панель по HTTP. +// +// Собственного TLS-слоя у админки нет: production-контракт HY2XS — +// HY2XS_UI_BIND_HOST=127.0.0.1 и HY2XS_UI_PUBLIC_ACCESS=false, то есть +// внутренний сервис. Ветка ListenAndServeTLS с путями к сертификатам из +// SQLite была наследием H UI, где панель публиковалась наружу сама. +func StartServer() error { return server.ListenAndServe() } @@ -36,30 +37,3 @@ func StopServer() error { return nil } - -func GetServerPortAndCert() (int64, string, string, error) { - port, crtPath, keyPath, err := GetPortAndCert() - if err != nil { - return 0, "", "", err - } - - if !util.IsPortAvailable(uint(port), "tcp") { - errMsg := fmt.Sprintf("port %d is taken", port) - logrus.Errorf(errMsg) - return 0, "", "", errors.New(errMsg) - } - - if crtPath != "" && !util.Exists(crtPath) { - errMsg := fmt.Sprintf("crt path: %s does not exist", crtPath) - logrus.Errorf(errMsg) - return 0, "", "", errors.New(errMsg) - } - - if keyPath != "" && !util.Exists(keyPath) { - errMsg := fmt.Sprintf("key path: %s does not exist", keyPath) - logrus.Errorf(errMsg) - return 0, "", "", errors.New(errMsg) - } - - return port, crtPath, keyPath, nil -} diff --git a/apps/util/export.go b/apps/util/export.go deleted file mode 100644 index 74da5e5..0000000 --- a/apps/util/export.go +++ /dev/null @@ -1,40 +0,0 @@ -package util - -import ( - "encoding/json" - "errors" - "github.com/sirupsen/logrus" - "gopkg.in/yaml.v3" - "hy2xs-admin/model/constant" - "os" -) - -// ExportFile t 0/json 1/yaml -func ExportFile(filePath string, data any, t int) error { - file, err := os.Create(filePath) - if err != nil { - logrus.Errorf("ExportFile create file err filePath: %s err: %v", filePath, err) - return errors.New(constant.SysError) - } - defer file.Close() - var bytes []byte - if t == 0 { - bytes, err = json.MarshalIndent(data, "", " ") - if err != nil { - logrus.Errorf("ExportFile Marshal json err filePath: %s err: %v", filePath, err) - return errors.New(constant.SysError) - } - } else if t == 1 { - bytes, err = yaml.Marshal(&data) - if err != nil { - logrus.Errorf("ExportFile Marshal yaml err filePath: %s err: %v", filePath, err) - return errors.New(constant.SysError) - } - } - _, err = file.Write(bytes) - if err != nil { - logrus.Errorf("ExportFile writer WriteString err filePath: %s err: %v", filePath, err) - return errors.New(constant.SysError) - } - return nil -} diff --git a/package/systemd/hy2xs-admin.service b/package/systemd/hy2xs-admin.service index 3f88b55..d33af5b 100644 --- a/package/systemd/hy2xs-admin.service +++ b/package/systemd/hy2xs-admin.service @@ -11,8 +11,6 @@ SupplementaryGroups=systemd-journal WorkingDirectory={{INSTALL_DIR}} EnvironmentFile=/etc/hy2xs/hy2xs.env Environment=GIN_MODE=release -Environment=HUI_DATA={{DATA_DIR}}/ -Environment=HUI_LOG={{LOG_DIR}} ExecStart={{INSTALL_DIR}}/hy2xs-admin -p {{UI_PORT}} Restart=on-failure RestartSec=5s