fix(admin): убрать каналы утечки секретов и остатки H UI из runtime

Экспорт в панели формировался через 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 и был единственным элементом вне палитры.
This commit is contained in:
2026-08-27 20:49:07 +05:00
parent 3a4ce9c751
commit b52fac1394
33 changed files with 677 additions and 608 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ func runReset(cmd *cobra.Command, args []string) {
fmt.Println(err.Error()) fmt.Println(err.Error())
os.Exit(1) os.Exit(1)
} }
if err = dao.InitSql(""); err != nil { if err = dao.InitSql(); err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
os.Exit(1) os.Exit(1)
} }
+42 -23
View File
@@ -15,6 +15,8 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
) )
func resolveUiBindHost() (string, error) { func resolveUiBindHost() (string, error) {
@@ -29,31 +31,30 @@ func resolveUiBindHost() (string, error) {
return host, nil 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 { func runServer(port string) error {
defer releaseResource() defer releaseResource()
middleware.InitLog() 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() uiPort, err := resolveUiPort(port)
router.Router(r, config.Value)
serverPort, crtPath, keyPath, err := service.GetServerPortAndCert()
if err != nil { if err != nil {
return err return err
} }
@@ -62,8 +63,27 @@ func runServer(port string) error {
return err return err
} }
service.InitServer(fmt.Sprintf("%s:%d", bindHost, serverPort), r) if err := initFile(); err != nil {
if err := service.StartServer(crtPath, keyPath); err != nil && err != http.ErrServerClosed { 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) logrus.Errorf("start server err: %v", err)
return errors.New("start server err") return errors.New("start server err")
} }
@@ -84,7 +104,6 @@ func initFile() error {
constant.LogDir, constant.LogDir,
constant.SqliteDBDir, constant.SqliteDBDir,
constant.BinDir, constant.BinDir,
constant.ExportPathDir,
filepath.Dir(constant.SqliteDBPath), filepath.Dir(constant.SqliteDBPath),
} }
for _, item := range dirs { for _, item := range dirs {
+21 -119
View File
@@ -1,29 +1,24 @@
package controller package controller
import ( import (
"encoding/json"
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
"hy2xs-admin/util"
"io"
"path/filepath"
"strconv"
"strings"
"time" "time"
) )
// Ключи, которыми владеет install-оркестратор: панель обязана отказать в
// записи, а не молча создать второй источник истины.
//
// Сетевых настроек самой панели в этом списке больше нет, потому что их нет
// и в базе: порт и bind приходят из контракта запуска, TLS терминируется
// снаружи. См. model/constant/config.go.
func isOrchestratorManagedConfigKey(key string) bool { func isOrchestratorManagedConfigKey(key string) bool {
switch key { switch key {
case constant.HUIWebPort, case constant.Hysteria2Enable,
constant.HUIWebContext,
constant.HUICrtPath,
constant.HUIKeyPath,
constant.Hysteria2Enable,
constant.Hysteria2Config, constant.Hysteria2Config,
constant.Hysteria2TrafficStatsSecret: constant.Hysteria2TrafficStatsSecret:
return true return true
@@ -38,12 +33,6 @@ func UpdateConfigs(c *gin.Context) {
return return
} }
port, crtPath, keyPath, err := service.GetPortAndCert()
if err != nil {
vo.Fail(err.Error(), c)
return
}
needRestart := false needRestart := false
for _, item := range configsUpdateDto.ConfigUpdateDtos { for _, item := range configsUpdateDto.ConfigUpdateDtos {
@@ -55,44 +44,6 @@ func UpdateConfigs(c *gin.Context) {
return 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 { if key == constant.ResetTrafficCron {
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron) resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron)
if err != nil { if err != nil {
@@ -196,66 +147,17 @@ func ExportHysteria2Config(c *gin.Context) {
c.Data(200, "application/octet-stream", sanitized) c.Data(200, "application/octet-stream", sanitized)
} }
func ExportConfig(c *gin.Context) { // Generic-выгрузки и загрузки таблицы `config` здесь нет намеренно.
configs, err := service.ListConfigNotIn([]string{constant.Hysteria2Config}) //
if err != nil { // Она отдавала таблицу целиком, исключая только сырой Hysteria YAML, а в той
vo.Fail(err.Error(), c) // же таблице лежат JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и
return // HYSTERIA2_TRAFFIC_STATS_SECRET. Кнопка «Export» в панели выгружала их в
} // открытом виде, а зеркальный импорт позволял их подменить — включая ключ
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 { // Осмысленного production-сценария у этой пары не было: конфигурацией сервера
vo.Fail(err.Error(), c) // владеет install-оркестратор, перенос пиров делают ImportPeer/ExportPeer, а
return // серверный конфиг Hysteria выгружается отдельным санитизирующим маршрутом.
} // Поэтому маршруты удалены, а не оставлены с фильтром: список ключей,
// требующий ручного сопровождения, снова разошёлся бы со схемой базы.
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)
}
+47 -16
View File
@@ -1,21 +1,21 @@
package controller package controller
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
"hy2xs-admin/util"
) )
func resolveID(c *gin.Context) (int64, error) { func resolveID(c *gin.Context) (int64, error) {
@@ -129,30 +129,55 @@ func GetPeer(c *gin.Context) {
vo.Success(peer, c) vo.Success(peer, c)
} }
// peerImportMaxBytes — предел размера загружаемого файла.
const peerImportMaxBytes = 2 * 1024 * 1024
// ImportPeer принимает выгрузку, сделанную ExportPeer.
//
// Импорт — полноценная дверь в таблицу пиров, поэтому его вход проверяется
// так же строго, как обычное создание пира: разбор JSON ограничен по размеру,
// неизвестные поля отклоняются, а содержимое записей валидируется в
// service.ValidatePeerImportBatch до первой записи в базу.
func ImportPeer(c *gin.Context) { func ImportPeer(c *gin.Context) {
file, header, err := c.Request.FormFile("file") file, header, err := c.Request.FormFile("file")
if err != nil { if err != nil {
vo.Fail(constant.SysError, c) vo.Fail(constant.SysError, c)
return return
} }
if header.Size > 1024*1024*2 { defer func() { _ = file.Close() }()
if header.Size > peerImportMaxBytes {
vo.Fail("the file is too big", c) vo.Fail("the file is too big", c)
return return
} }
if !strings.HasSuffix(header.Filename, ".json") { if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") {
vo.Fail(constant.InvalidError, c) vo.Fail(constant.InvalidError, c)
return return
} }
content, err := io.ReadAll(file)
// Заявленный Size — это то, что сказал клиент; читаем с собственным
// пределом, чтобы расхождение не превращалось в чтение произвольного
// объёма в память.
content, err := io.ReadAll(io.LimitReader(file, peerImportMaxBytes+1))
if err != nil { if err != nil {
vo.Fail("json file read err", c) vo.Fail("json file read err", c)
return return
} }
if len(content) > peerImportMaxBytes {
vo.Fail("the file is too big", c)
return
}
var peerExports []bo.PeerExport 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) vo.Fail("content Unmarshal err", c)
return return
} }
if err = service.UpsertPeerExport(peerExports); err != nil { if err = service.UpsertPeerExport(peerExports); err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
@@ -160,6 +185,13 @@ func ImportPeer(c *gin.Context) {
vo.Success(nil, c) vo.Success(nil, c)
} }
// ExportPeer отдаёт выгрузку пиров прямо в ответ, не создавая файл на сервере.
//
// Раньше выгрузка шла через os.Create в /var/lib/hy2xs-admin/export и файл
// оставался там навсегда. При includeSecrets=true это означало расшифрованные
// секреты пиров — фактические учётные данные доступа — в открытом виде на
// диске, накапливающиеся с каждым нажатием кнопки. Артефакт, который покидает
// сервер, не должен существовать на сервере дольше самого запроса.
func ExportPeer(c *gin.Context) { func ExportPeer(c *gin.Context) {
includeSecrets := strings.EqualFold(strings.TrimSpace(c.Query("includeSecrets")), "true") includeSecrets := strings.EqualFold(strings.TrimSpace(c.Query("includeSecrets")), "true")
peerExports, err := service.ListExportPeer(includeSecrets) peerExports, err := service.ListExportPeer(includeSecrets)
@@ -167,20 +199,19 @@ func ExportPeer(c *gin.Context) {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return 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")) 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-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary") c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) 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) { func ReleaseKickPeer(c *gin.Context) {
-12
View File
@@ -4,7 +4,6 @@ import (
"errors" "errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"time" "time"
@@ -53,14 +52,3 @@ func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error)
} }
return configs, nil 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
}
+7 -11
View File
@@ -48,19 +48,18 @@ func InitSqliteDB() error {
return nil return nil
} }
func InitSql(port string) error { // InitSql поднимает базу админки.
//
// Порт сюда больше не передаётся: раньше InitSql записывал в config тот самый
// порт, который получил аргументом из ExecStart, а startup тут же читал его
// обратно. База не была источником этого факта ни на одном шаге.
func InitSql() error {
if err := InitSqliteDB(); err != nil { if err := InitSqliteDB(); err != nil {
return err return err
} }
if err := runMigrations(); err != nil { if err := runMigrations(); err != nil {
return err 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 { if err := ensureSecureBootstrapAdmin(); err != nil {
return err return err
} }
@@ -307,11 +306,8 @@ func runMigrations() error {
} }
func seedBaseConfig() error { func seedBaseConfig() error {
// Сетевых настроек панели здесь нет: ими владеет оркестратор.
defaults := map[string]string{ defaults := map[string]string{
constant.HUIWebPort: "8080",
constant.HUIWebContext: "/",
constant.HUICrtPath: "",
constant.HUIKeyPath: "",
constant.JwtSecret: "", constant.JwtSecret: "",
constant.Hysteria2Enable: "0", constant.Hysteria2Enable: "0",
constant.Hysteria2Config: "", constant.Hysteria2Config: "",
+3 -19
View File
@@ -11,6 +11,9 @@ import {
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку: // Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
// им владеет install-оркестратор. Клиентов записи, импорта, перезапуска и // им владеет install-оркестратор. Клиентов записи, импорта, перезапуска и
// загрузки сертификатов здесь нет — соответствующих маршрутов не существует. // загрузки сертификатов здесь нет — соответствующих маршрутов не существует.
//
// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и
// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer.
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> { export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
return request({ 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 { export function exportHysteria2ConfigApi(): AxiosPromise {
return request({ return request({
url: "/config/exportHysteria2Config", url: "/config/exportHysteria2Config",
-12
View File
@@ -97,8 +97,6 @@ export default {
yes: "Yes", yes: "Yes",
no: "No", no: "No",
securityRisk: "Security Risks", 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. <a href="/#/admin/change-password" style="color: #00BFFF">Click here</a> to change`,
noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Click here</a> to enable`,
required: "Required", required: "Required",
warning: "Warning", warning: "Warning",
fileFormatUnsupported: "File format not supported", fileFormatUnsupported: "File format not supported",
@@ -180,25 +178,15 @@ export default {
}, },
config: { config: {
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure", orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
huiWebPort: "HY2XS admin Web Port",
huiWebContext: "HY2XS admin Web Context",
hysteria2TrafficTime: "Hysteria2 Traffic Time", 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", resetTrafficCron: "Reset traffic schedule task",
resetTrafficCronTip: resetTrafficCronTip:
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3", "Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3",
resetTrafficMonth: "Run once a month, midnight, first of month", resetTrafficMonth: "Run once a month, midnight, first of month",
resetTrafficWeek: "Run once a week, midnight between Sat/Sun", 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", invalidTrafficTime: "Field must be a number with up to one decimal place",
}, },
monitor: { monitor: {
huiVersion: "HY2XS admin Version",
cpuPercent: "CPU Usage", cpuPercent: "CPU Usage",
memPercent: "Memory Usage", memPercent: "Memory Usage",
diskPercent: "Disk Usage", diskPercent: "Disk Usage",
-12
View File
@@ -94,8 +94,6 @@ export default {
yes: "Да", yes: "Да",
no: "Нет", no: "Нет",
securityRisk: "Риски безопасности", securityRisk: "Риски безопасности",
defaultPassTip: `Смените пароль по умолчанию как можно скорее. <a href="/#/admin/change-password" style="color: #00BFFF">Перейти к смене</a>`,
noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Открыть настройки</a>`,
required: "Обязательное поле", required: "Обязательное поле",
warning: "Внимание", warning: "Внимание",
fileFormatUnsupported: "Формат файла не поддерживается", fileFormatUnsupported: "Формат файла не поддерживается",
@@ -176,25 +174,15 @@ export default {
}, },
config: { config: {
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure", orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
huiWebPort: "Порт HY2XS admin",
huiWebContext: "Web-контекст HY2XS admin",
hysteria2TrafficTime: "Период учёта трафика Hysteria2", hysteria2TrafficTime: "Период учёта трафика Hysteria2",
huiCrtPath: "Путь к CRT HY2XS admin",
huiKeyPath: "Путь к KEY HY2XS admin",
useHysteria2Cert: "Использовать сертификат Hysteria2",
huiHttps: "Включить HTTPS панели",
resetTrafficCron: "Расписание сброса трафика", resetTrafficCron: "Расписание сброса трафика",
resetTrafficCronTip: "Cron-выражение для планового сброса трафика", resetTrafficCronTip: "Cron-выражение для планового сброса трафика",
resetTrafficMonth: "Раз в месяц, в полночь первого дня", resetTrafficMonth: "Раз в месяц, в полночь первого дня",
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем", resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
mustBeInteger: "Поле должно быть целым числом",
invalidWebContext:
"Поле должно начинаться с / и содержать только строчные буквы (a-z) и цифры (0-9)",
invalidTrafficTime: invalidTrafficTime:
"Поле должно быть числом максимум с одним знаком после запятой", "Поле должно быть числом максимум с одним знаком после запятой",
}, },
monitor: { monitor: {
huiVersion: "Версия HY2XS admin",
cpuPercent: "CPU", cpuPercent: "CPU",
memPercent: "Память", memPercent: "Память",
diskPercent: "Диск", diskPercent: "Диск",
+2
View File
@@ -3,7 +3,9 @@ import { useAdminStoreHook } from "@/store/modules/admin";
import { usePermissionStoreHook } from "@/store/modules/permission"; import { usePermissionStoreHook } from "@/store/modules/permission";
import NProgress from "nprogress"; import NProgress from "nprogress";
// Порядок важен: сначала vendor-база, затем тема HY2XS поверх неё.
import "nprogress/nprogress.css"; import "nprogress/nprogress.css";
import "@/styles/nprogress.scss";
NProgress.configure({ showSpinner: false }); // Индикатор загрузки NProgress.configure({ showSpinner: false }); // Индикатор загрузки
+7 -2
View File
@@ -26,9 +26,14 @@
box-sizing: content-box; box-sizing: content-box;
} }
// Цвет фона выбранной строки // Цвет фона выбранной строки.
//
// Раньше здесь стоял литерал `#e1f3d8b5` — бледно-зелёный из исходного
// admin-шаблона, не имеющий отношения к палитре HY2XS. Подсветка выделения —
// это семантика «активный элемент», поэтому берётся самый светлый оттенок
// брендового primary.
.el-table__body tr.current-row td { .el-table__body tr.current-row td {
background-color: #e1f3d8b5 !important; background-color: var(--el-color-primary-light-9) !important;
} }
// Единая высота header у card // Единая высота header у card
+27
View File
@@ -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);
}
}
+6 -1
View File
@@ -3,7 +3,12 @@ import { useAdminStoreHook } from "@/store/modules/admin";
import i18n from "@/lang/index"; import i18n from "@/lang/index";
const dynamicBase = (window as any).__dynamic_base__ || ""; 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; const t = i18n.global.t;
// Создание axios instance // Создание axios instance
const service = axios.create({ const service = axios.create({
+12 -208
View File
@@ -7,31 +7,6 @@
{{ $t("common.save") }} {{ $t("common.save") }}
</el-button> </el-button>
</el-form-item> </el-form-item>
<el-form-item>
<el-upload
v-model:file-list="fileList"
:http-request="handleImport"
:show-file-list="false"
accept=".json"
:limit="1"
:before-upload="beforeImport"
>
<el-button>
<template #icon>
<i-ep-upload />
</template>
{{ $t("common.import") }}
</el-button>
</el-upload>
</el-form-item>
<el-form-item>
<el-button @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item>
</el-form> </el-form>
</div> </div>
@@ -49,22 +24,6 @@
:model="dataForm" :model="dataForm"
label-position="top" label-position="top"
> >
<el-form-item :label="$t('config.huiWebPort')" prop="huiWebPort">
<el-input
v-model="dataForm.huiWebPort"
:placeholder="$t('config.huiWebPort')"
disabled
clearable
/>
</el-form-item>
<el-form-item :label="$t('config.huiWebContext')" prop="huiWebContext">
<el-input
v-model="dataForm.huiWebContext"
:placeholder="$t('config.huiWebContext')"
disabled
clearable
/>
</el-form-item>
<el-form-item <el-form-item
:label="$t('config.hysteria2TrafficTime')" :label="$t('config.hysteria2TrafficTime')"
prop="hysteria2TrafficTime" prop="hysteria2TrafficTime"
@@ -75,47 +34,6 @@
clearable clearable
/> />
</el-form-item> </el-form-item>
<el-form-item :label="$t('config.huiHttps')" prop="huiHttps">
<el-select
v-model="huiHttps"
style="width: 50%"
ref="huiHttpsRef"
disabled
>
<el-option
v-for="item in huiHttpsList"
:key="item.key"
:label="item.key"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="huiHttps"
:label="$t('config.huiCrtPath')"
prop="huiCrtPath"
>
<el-input
v-model="dataForm.huiCrtPath"
:placeholder="$t('config.huiCrtPath')"
style="width: 50%"
disabled
clearable
/>
</el-form-item>
<el-form-item
v-if="huiHttps"
:label="$t('config.huiKeyPath')"
prop="huiKeyPath"
>
<el-input
v-model="dataForm.huiKeyPath"
:placeholder="$t('config.huiKeyPath')"
style="width: 50%"
disabled
clearable
/>
</el-form-item>
<el-tooltip <el-tooltip
:content="$t('config.resetTrafficCronTip')" :content="$t('config.resetTrafficCronTip')"
placement="bottom" placement="bottom"
@@ -153,66 +71,32 @@ export default {
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
// Сетевые настройки самой панели (порт, web-контекст, HTTPS, пути к
// сертификатам) здесь отсутствуют намеренно: ими владеет install-оркестратор
// через /etc/hy2xs/hy2xs.env и systemd-юнит. Раньше они лежали в SQLite и
// показывались тут в disabled-виде — второй источник истины, из которого
// ничего нельзя было изменить.
//
// Generic import/export конфигурации удалён: он выгружал таблицу `config`
// целиком, вместе с JWT- и peer-ключами.
import { Select } from "@element-plus/icons-vue"; import { Select } from "@element-plus/icons-vue";
import { import { listConfigApi, updateConfigsApi } from "@/api/config";
exportConfigApi,
importConfigApi,
listConfigApi,
updateConfigsApi,
} from "@/api/config";
import { ConfigsUpdateDto } from "@/api/config/types"; import { ConfigsUpdateDto } from "@/api/config/types";
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute();
const dataFormRef = ref(ElForm); const dataFormRef = ref(ElForm);
const huiHttpsRef = ref<any>(null);
const huiWebPortKey = "H_UI_WEB_PORT";
const huiWebContext = "H_UI_WEB_CONTEXT";
const hysteria2TrafficTimeKey = "HYSTERIA2_TRAFFIC_TIME"; const hysteria2TrafficTimeKey = "HYSTERIA2_TRAFFIC_TIME";
const huiCrtPathKey = "H_UI_CRT_PATH";
const huiKeyPathKey = "H_UI_KEY_PATH";
const resetTrafficCronKey = "RESET_TRAFFIC_CRON"; const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
const huiHttpsList = [
{ key: t("common.yes"), value: 1 },
{ key: t("common.no"), value: 0 },
];
const cronResetTraffic = [ const cronResetTraffic = [
{ key: t("config.resetTrafficMonth"), value: "@monthly" }, { key: t("config.resetTrafficMonth"), value: "@monthly" },
{ key: t("config.resetTrafficWeek"), value: "@weekly" }, { key: t("config.resetTrafficWeek"), value: "@weekly" },
]; ];
const dataFormRules = { const dataFormRules = {
huiWebPort: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^\d+$/,
message: t("config.mustBeInteger"),
trigger: ["change", "blur"],
},
],
huiWebContext: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^\/([a-z0-9]+(\/[a-z0-9]+)*)?$/,
message: t("config.invalidWebContext"),
trigger: ["change", "blur"],
},
],
hysteria2TrafficTime: [ hysteria2TrafficTime: [
{ {
required: true, required: true,
@@ -229,18 +113,12 @@ const dataFormRules = {
const state = reactive({ const state = reactive({
dataForm: { dataForm: {
huiWebPort: "8081",
huiWebContext: "/",
hysteria2TrafficTime: "1", hysteria2TrafficTime: "1",
huiCrtPath: "",
huiKeyPath: "",
resetTrafficCron: "", resetTrafficCron: "",
}, },
huiHttps: 0,
fileList: [] as UploadFile[],
}); });
const { dataForm, huiHttps, fileList } = toRefs(state); const { dataForm } = toRefs(state);
const submitForm = () => { const submitForm = () => {
dataFormRef.value.validate((valid: boolean) => { dataFormRef.value.validate((valid: boolean) => {
@@ -265,94 +143,20 @@ const submitForm = () => {
const setConfig = async () => { const setConfig = async () => {
const { data } = await listConfigApi({ const { data } = await listConfigApi({
keys: [ keys: [hysteria2TrafficTimeKey, resetTrafficCronKey],
huiCrtPathKey,
huiWebContext,
huiKeyPathKey,
huiWebPortKey,
hysteria2TrafficTimeKey,
resetTrafficCronKey,
],
}); });
data.forEach((configVo) => { data.forEach((configVo) => {
if (configVo.key === huiWebPortKey) { if (configVo.key === hysteria2TrafficTimeKey) {
state.dataForm.huiWebPort = configVo.value;
} else if (configVo.key === huiWebContext) {
state.dataForm.huiWebContext = configVo.value;
} else if (configVo.key === hysteria2TrafficTimeKey) {
state.dataForm.hysteria2TrafficTime = configVo.value; state.dataForm.hysteria2TrafficTime = configVo.value;
} else if (configVo.key === huiCrtPathKey) {
state.dataForm.huiCrtPath = configVo.value;
} else if (configVo.key === huiKeyPathKey) {
state.dataForm.huiKeyPath = configVo.value;
} else if (configVo.key === resetTrafficCronKey) { } else if (configVo.key === resetTrafficCronKey) {
state.dataForm.resetTrafficCron = configVo.value; state.dataForm.resetTrafficCron = configVo.value;
} }
}); });
if (state.dataForm.huiCrtPath != "" && state.dataForm.huiKeyPath != "") {
state.huiHttps = 1;
}
};
const handleImport = async (params: UploadRequestOptions) => {
if (state.fileList.length > 0) {
try {
let formData = new FormData();
formData.append("file", params.file);
await importConfigApi(formData);
ElMessage.success(t("common.success"));
state.fileList = [];
} catch (e) {
/* empty */
} finally {
await setConfig();
}
}
};
const beforeImport = (file: UploadRawFile) => {
if (!file.name.endsWith(".json")) {
ElMessage.error(t("common.fileFormatUnsupported"));
return false;
}
if (file.size / 1024 / 1024 > 2) {
ElMessage.error(t("common.fileTooLarge"));
return false;
}
};
const handleExport = async () => {
try {
let response = await exportConfigApi();
const blob = new Blob([response.data], {
type: "application/octet-stream",
});
let url = window.URL.createObjectURL(blob);
let a = document.createElement("a");
document.body.appendChild(a);
a.href = url;
let dis = response.headers["content-disposition"];
a.download = dis.split("attachment; filename=")[1];
// Имитация клика для скачивания
a.click();
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.success"));
} catch (e) {
/* empty */
}
}; };
onMounted(() => { onMounted(() => {
setConfig(); setConfig();
if (route.query.focus === "huiHttps") {
nextTick(() => {
const input = huiHttpsRef.value.$el.querySelector(".el-input__inner");
if (input) {
input.focus();
}
});
}
}); });
</script> </script>
+2 -2
View File
@@ -61,7 +61,7 @@ function back() {
.pan-back-btn { .pan-back-btn {
color: #fff; color: #fff;
background: #008489; background: var(--el-color-primary);
border: none !important; border: none !important;
} }
@@ -90,7 +90,7 @@ function back() {
} }
a { a {
color: #008489; color: var(--el-color-primary);
text-decoration: none; text-decoration: none;
&:hover { &:hover {
+2 -2
View File
@@ -209,7 +209,7 @@ function message() {
font-size: 32px; font-size: 32px;
font-weight: bold; font-weight: bold;
line-height: 40px; line-height: 40px;
color: #1482f0; color: var(--el-color-primary);
opacity: 0; opacity: 0;
animation-name: slideUp; animation-name: slideUp;
animation-duration: 0.5s; animation-duration: 0.5s;
@@ -251,7 +251,7 @@ function message() {
color: #fff; color: #fff;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
background: #1482f0; background: var(--el-color-primary);
border-radius: 100px; border-radius: 100px;
opacity: 0; opacity: 0;
animation-name: slideUp; animation-name: slideUp;
+2 -1
View File
@@ -21,7 +21,8 @@ const pathSrc = path.resolve(__dirname, "src");
const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g; const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g;
const DRIVE_LETTER_REGEX = /^[a-z]:/i; const DRIVE_LETTER_REGEX = /^[a-z]:/i;
const DEV_SERVER_PORT = 8080; const DEV_SERVER_PORT = 8080;
const API_BASE = "/hui"; // Синхронизировано с src/utils/request.ts и constant.AdminAPIBase.
const API_BASE = "/api";
export default defineConfig((): UserConfig => { export default defineConfig((): UserConfig => {
return { return {
+27
View File
@@ -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"
)
+8 -4
View File
@@ -1,10 +1,14 @@
package constant package constant
// Ключи таблицы `config` — то, чем действительно владеет админка.
//
// Сетевых настроек самой панели (порт, web-контекст, пути к сертификатам)
// здесь больше нет. Они достались от H UI, где панель конфигурировала себя
// сама, и в архитектуре HY2XS были вторым источником истины: порт приходит из
// HY2XS_UI_PORT через systemd, bind — из HY2XS_UI_BIND_HOST, TLS
// терминируется на внешнем слое. Панель записывала в SQLite тот же порт,
// который ей передали аргументом, и тут же читала его обратно.
const ( const (
HUIWebPort = "H_UI_WEB_PORT"
HUIWebContext = "H_UI_WEB_CONTEXT"
HUICrtPath = "H_UI_CRT_PATH"
HUIKeyPath = "H_UI_KEY_PATH"
JwtSecret = "JWT_SECRET" JwtSecret = "JWT_SECRET"
PeerSecretKey = "PEER_SECRET_KEY" PeerSecretKey = "PEER_SECRET_KEY"
PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY" PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY"
+13 -4
View File
@@ -13,14 +13,23 @@ func getenv(name string, fallback string) string {
return value return value
} }
// Пути читаются напрямую из canonical env HY2XS.
//
// Раньше здесь стояли HUI_DATA/HUI_LOG, а systemd-юнит перекладывал в них
// значения из HY2XS_DATA_DIR/HY2XS_LOG_DIR. Этот мост от старого H UI не давал
// ничего, кроме второго имени у той же величины, и делал юнит обязательным
// участником контракта конфигурации.
var ( var (
DataRootDir = getenv("HUI_DATA", "/var/lib/hy2xs-admin") DataRootDir = getenv("HY2XS_DATA_DIR", "/var/lib/hy2xs-admin")
LogDir = getenv("HUI_LOG", "/var/log/hy2xs") LogDir = getenv("HY2XS_LOG_DIR", "/var/log/hy2xs")
SqliteDBDir = DataRootDir SqliteDBDir = DataRootDir
BinDir = filepath.Join(DataRootDir, "bin") BinDir = filepath.Join(DataRootDir, "bin")
ExportPathDir = filepath.Join(DataRootDir, "export")
SqliteDBPath = filepath.Join(DataRootDir, "h_ui.db") // Каталога для выгрузок здесь нет намеренно: артефакты экспорта
// формируются в памяти и отдаются прямо в ответ. Персистентный
// export-каталог накапливал на диске JSON с секретами.
SqliteDBPath = filepath.Join(DataRootDir, "hy2xs-admin.db")
Hysteria2ConfigPath = "/etc/hysteria/config.yaml" Hysteria2ConfigPath = "/etc/hysteria/config.yaml"
Hysteria2BinPath = "/usr/local/bin/hysteria" Hysteria2BinPath = "/usr/local/bin/hysteria"
+4 -2
View File
@@ -10,6 +10,10 @@ import (
// importHysteria2Config), перезапуска панели и загрузки сертификатов удалены, // importHysteria2Config), перезапуска панели и загрузки сертификатов удалены,
// а не оставлены заглушками: маршрут, который всегда отвечает «feature // а не оставлены заглушками: маршрут, который всегда отвечает «feature
// disabled», вводит в заблуждение и остаётся точкой входа. // disabled», вводит в заблуждение и остаётся точкой входа.
//
// По той же причине здесь нет generic exportConfig/importConfig: они отдавали
// и принимали таблицу `config` целиком, вместе с JWT- и peer-ключами.
// См. комментарий в controller/config.go.
func initConfigRouter(configApi *gin.RouterGroup) { func initConfigRouter(configApi *gin.RouterGroup) {
config := configApi.Group("/config") config := configApi.Group("/config")
{ {
@@ -18,7 +22,5 @@ func initConfigRouter(configApi *gin.RouterGroup) {
config.POST("/listConfig", controller.ListConfig) config.POST("/listConfig", controller.ListConfig)
config.GET("/getHysteria2Config", controller.GetHysteria2Config) config.GET("/getHysteria2Config", controller.GetHysteria2Config)
config.POST("/exportHysteria2Config", controller.ExportHysteria2Config) config.POST("/exportHysteria2Config", controller.ExportHysteria2Config)
config.POST("/exportConfig", controller.ExportConfig)
config.POST("/importConfig", controller.ImportConfig)
} }
} }
+6 -5
View File
@@ -9,9 +9,10 @@ import (
// и перезапуск выполняются install-оркестратором. Маршрутов updater'а здесь // и перезапуск выполняются install-оркестратором. Маршрутов updater'а здесь
// нет намеренно — API-контракт не должен обещать операцию, которой у продукта // нет намеренно — API-контракт не должен обещать операцию, которой у продукта
// принципиально не существует, и не должен нести лишнюю attack surface. // принципиально не существует, и не должен нести лишнюю attack surface.
func initHysteria2MachineAuthRouter(hysteria2Api *gin.RouterGroup) { //
hysteria2 := hysteria2Api.Group("/hysteria2") // Единственный маршрут этой группы — machine-auth. Группа уже смонтирована на
{ // constant.HysteriaMachineAuthPath, поэтому относительный путь здесь пустой:
hysteria2.POST("/auth", controller.Hysteria2Auth) // полный путь объявлен ровно в одном месте.
} func initHysteria2MachineAuthRouter(machineAuthAPI *gin.RouterGroup) {
machineAuthAPI.POST("", controller.Hysteria2Auth)
} }
+26 -22
View File
@@ -7,10 +7,16 @@ import (
"hy2xs-admin/middleware" "hy2xs-admin/middleware"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"os" "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) { router.GET("/healthz", func(c *gin.Context) {
sqliteReady := dao.IsSqliteReady() sqliteReady := dao.IsSqliteReady()
configReadable := false configReadable := false
@@ -36,34 +42,32 @@ func Router(router *gin.Engine, huiWebContext *string) {
}) })
}) })
relativePath := "/" frontend.InitFrontend(router, "/")
if huiWebContext != nil && strings.HasPrefix(*huiWebContext, "/") {
relativePath = *huiWebContext
}
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") authAPI := api.Group("")
machineApi.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler()) authAPI.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler())
initHysteria2MachineAuthRouter(machineApi) initAuthRouter(authAPI)
authApi := globalGroup.Group("/hui") adminAPI := api.Group("")
authApi.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler()) adminAPI.Use(
initAuthRouter(authApi)
huiAdminApi := globalGroup.Group("/hui")
huiAdminApi.Use(
middleware.FilterHandler(), middleware.FilterHandler(),
middleware.LogHandler(), middleware.LogHandler(),
middleware.RateLimiterHandler(), middleware.RateLimiterHandler(),
middleware.JWTHandler(), middleware.JWTHandler(),
middleware.AdminHandler(), middleware.AdminHandler(),
) )
initAdminRouter(huiAdminApi) initAdminRouter(adminAPI)
initDashboardRouter(huiAdminApi) initDashboardRouter(adminAPI)
initPeerRouter(huiAdminApi) initPeerRouter(adminAPI)
initConfigRouter(huiAdminApi) initConfigRouter(adminAPI)
initLogRouter(huiAdminApi) initLogRouter(adminAPI)
} }
-36
View File
@@ -27,10 +27,6 @@ func ListConfig(keys []string) ([]entity.Config, error) {
return dao.ListConfig("key in ?", keys) 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) { func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
var serverConfig bo.Hysteria2ServerConfig var serverConfig bo.Hysteria2ServerConfig
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config) config, err := dao.GetConfig("key = ?", constant.Hysteria2Config)
@@ -53,10 +49,6 @@ func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
return serverConfig, nil return serverConfig, nil
} }
func UpsertConfig(configs []entity.Config) error {
return dao.UpsertConfig(configs)
}
func GetHysteria2ApiPort() (int64, error) { func GetHysteria2ApiPort() (int64, error) {
hysteria2Config, err := GetHysteria2Config() hysteria2Config, err := GetHysteria2Config()
if err != nil { if err != nil {
@@ -100,31 +92,3 @@ func parseTrafficStatsPort(listen string) (int64, error) {
} }
return port, nil 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
}
+24 -9
View File
@@ -154,20 +154,35 @@ func redactNode(node *yaml.Node, path []string) {
redactSubtree(value) redactSubtree(value)
continue continue
} }
if value.Kind == yaml.ScalarNode && looksLikeURLKey(key) {
value.Value = sanitizeURLValue(value.Value)
value.Tag = "!!str"
value.Style = 0
continue
}
redactNode(value, childPath) 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 { // sanitizeURLScalar применяет URL-санитайзер к скаляру, не трогая ни его тип,
lowered := strings.ToLower(key) // ни значения, которые URL не являются.
return lowered == "url" || lowered == "addr" || strings.HasSuffix(lowered, "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) { func redactSubtree(node *yaml.Node) {
+75 -4
View File
@@ -26,7 +26,7 @@ acme:
auth: auth:
type: http type: http
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 insecure: false
userpass: userpass:
alice: alice-password alice: alice-password
@@ -71,6 +71,11 @@ someFutureUpstreamFeature:
list: list:
- a - a
- b - 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 { func sanitizeForTest(t *testing.T, raw string) string {
@@ -93,6 +98,9 @@ func TestSanitizeHysteria2ConfigYaml_RemovesSecrets(t *testing.T) {
"bob-password", "bob-password",
"proxy-password", "proxy-password",
"super-secret-token", "super-secret-token",
"svc-p4ss",
"endpoint-token",
"mirror-p4ss",
} }
for _, secret := range leaked { for _, secret := range leaked {
if strings.Contains(sanitized, secret) { if strings.Contains(sanitized, secret) {
@@ -152,7 +160,7 @@ func TestSanitizeHysteria2ConfigYaml_KeepsNonSecretOperationalFields(t *testing.
func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testing.T) { func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig) 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) t.Fatalf("auth url shape was lost:\n%s", sanitized)
} }
if !strings.Contains(sanitized, "access_token="+RedactedPlaceholder) && 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) { func TestSanitizeURLValue(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -170,8 +241,8 @@ func TestSanitizeURLValue(t *testing.T) {
}{ }{
{ {
name: "strips access token", name: "strips access token",
in: "http://127.0.0.1:8080/hui/hysteria2/auth?access_token=abc123", in: "http://127.0.0.1:8080/internal/hysteria/auth?access_token=abc123",
mustKeep: []string{"127.0.0.1:8080", "/hui/hysteria2/auth"}, mustKeep: []string{"127.0.0.1:8080", "/internal/hysteria/auth"},
mustRemove: []string{"abc123"}, mustRemove: []string{"abc123"},
}, },
{ {
+14 -4
View File
@@ -205,11 +205,15 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
} }
func UpsertPeerExport(items []bo.PeerExport) error { func UpsertPeerExport(items []bo.PeerExport) error {
// Первый проход — только проверка. Импорт либо применяется целиком, либо
// не применяется вовсе: наполовину импортированный список пиров хуже
// отклонённого файла.
if err := ValidatePeerImportBatch(items); err != nil {
return err
}
for _, item := range items { for _, item := range items {
name := strings.TrimSpace(item.Name) name := strings.TrimSpace(item.Name)
if name == "" {
return errors.New(constant.InvalidError)
}
var existing entity.Peer var existing entity.Peer
var err error var err error
@@ -217,10 +221,16 @@ func UpsertPeerExport(items []bo.PeerExport) error {
if authID != "" { if authID != "" {
existing, err = dao.GetPeer("auth_id = ?", 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) 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 quota := item.QuotaBytes
expires := item.ExpiresAt expires := item.ExpiresAt
maxDevices := item.MaxDevices maxDevices := item.MaxDevices
+129
View File
@@ -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
}
+161
View File
@@ -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)
}
}
+7 -33
View File
@@ -3,9 +3,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"hy2xs-admin/util"
"net/http" "net/http"
"time" "time"
) )
@@ -19,10 +17,13 @@ func InitServer(addr string, handler http.Handler) {
} }
} }
func StartServer(crtPath string, keyPath string) error { // StartServer запускает панель по HTTP.
if crtPath != "" && keyPath != "" { //
return server.ListenAndServeTLS(crtPath, keyPath) // Собственного 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() return server.ListenAndServe()
} }
@@ -36,30 +37,3 @@ func StopServer() error {
return nil 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
}
-40
View File
@@ -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
}
-2
View File
@@ -11,8 +11,6 @@ SupplementaryGroups=systemd-journal
WorkingDirectory={{INSTALL_DIR}} WorkingDirectory={{INSTALL_DIR}}
EnvironmentFile=/etc/hy2xs/hy2xs.env EnvironmentFile=/etc/hy2xs/hy2xs.env
Environment=GIN_MODE=release Environment=GIN_MODE=release
Environment=HUI_DATA={{DATA_DIR}}/
Environment=HUI_LOG={{LOG_DIR}}
ExecStart={{INSTALL_DIR}}/hy2xs-admin -p {{UI_PORT}} ExecStart={{INSTALL_DIR}}/hy2xs-admin -p {{UI_PORT}}
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s