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:
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+42
-23
@@ -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 <port>")
|
||||
}
|
||||
value, err := strconv.Atoi(trimmed)
|
||||
if err != nil || value < 1 || value > 65535 {
|
||||
return 0, fmt.Errorf("port: %s is invalid", port)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func runServer(port string) error {
|
||||
defer releaseResource()
|
||||
|
||||
middleware.InitLog()
|
||||
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 {
|
||||
|
||||
+21
-119
@@ -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 выгружается отдельным санитизирующим маршрутом.
|
||||
// Поэтому маршруты удалены, а не оставлены с фильтром: список ключей,
|
||||
// требующий ручного сопровождения, снова разошёлся бы со схемой базы.
|
||||
|
||||
+47
-16
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+7
-11
@@ -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: "",
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
|
||||
// им владеет install-оркестратор. Клиентов записи, импорта, перезапуска и
|
||||
// загрузки сертификатов здесь нет — соответствующих маршрутов не существует.
|
||||
//
|
||||
// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и
|
||||
// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer.
|
||||
|
||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
||||
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",
|
||||
|
||||
@@ -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. <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",
|
||||
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",
|
||||
|
||||
@@ -94,8 +94,6 @@ export default {
|
||||
yes: "Да",
|
||||
no: "Нет",
|
||||
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: "Обязательное поле",
|
||||
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: "Диск",
|
||||
|
||||
@@ -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 }); // Индикатор загрузки
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -7,31 +7,6 @@
|
||||
{{ $t("common.save") }}
|
||||
</el-button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -49,22 +24,6 @@
|
||||
:model="dataForm"
|
||||
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
|
||||
:label="$t('config.hysteria2TrafficTime')"
|
||||
prop="hysteria2TrafficTime"
|
||||
@@ -75,47 +34,6 @@
|
||||
clearable
|
||||
/>
|
||||
</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
|
||||
:content="$t('config.resetTrafficCronTip')"
|
||||
placement="bottom"
|
||||
@@ -153,66 +71,32 @@ export default {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
exportConfigApi,
|
||||
importConfigApi,
|
||||
listConfigApi,
|
||||
updateConfigsApi,
|
||||
} from "@/api/config";
|
||||
import { listConfigApi, updateConfigsApi } from "@/api/config";
|
||||
import { ConfigsUpdateDto } from "@/api/config/types";
|
||||
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
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 huiCrtPathKey = "H_UI_CRT_PATH";
|
||||
const huiKeyPathKey = "H_UI_KEY_PATH";
|
||||
const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
|
||||
|
||||
const huiHttpsList = [
|
||||
{ key: t("common.yes"), value: 1 },
|
||||
{ key: t("common.no"), value: 0 },
|
||||
];
|
||||
|
||||
const cronResetTraffic = [
|
||||
{ key: t("config.resetTrafficMonth"), value: "@monthly" },
|
||||
{ key: t("config.resetTrafficWeek"), value: "@weekly" },
|
||||
];
|
||||
|
||||
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: [
|
||||
{
|
||||
required: true,
|
||||
@@ -229,18 +113,12 @@ const dataFormRules = {
|
||||
|
||||
const state = reactive({
|
||||
dataForm: {
|
||||
huiWebPort: "8081",
|
||||
huiWebContext: "/",
|
||||
hysteria2TrafficTime: "1",
|
||||
huiCrtPath: "",
|
||||
huiKeyPath: "",
|
||||
resetTrafficCron: "",
|
||||
},
|
||||
huiHttps: 0,
|
||||
fileList: [] as UploadFile[],
|
||||
});
|
||||
|
||||
const { dataForm, huiHttps, fileList } = toRefs(state);
|
||||
const { dataForm } = toRefs(state);
|
||||
|
||||
const submitForm = () => {
|
||||
dataFormRef.value.validate((valid: boolean) => {
|
||||
@@ -265,94 +143,20 @@ const submitForm = () => {
|
||||
|
||||
const setConfig = async () => {
|
||||
const { data } = await listConfigApi({
|
||||
keys: [
|
||||
huiCrtPathKey,
|
||||
huiWebContext,
|
||||
huiKeyPathKey,
|
||||
huiWebPortKey,
|
||||
hysteria2TrafficTimeKey,
|
||||
resetTrafficCronKey,
|
||||
],
|
||||
keys: [hysteria2TrafficTimeKey, resetTrafficCronKey],
|
||||
});
|
||||
|
||||
data.forEach((configVo) => {
|
||||
if (configVo.key === huiWebPortKey) {
|
||||
state.dataForm.huiWebPort = configVo.value;
|
||||
} else if (configVo.key === huiWebContext) {
|
||||
state.dataForm.huiWebContext = configVo.value;
|
||||
} else if (configVo.key === hysteria2TrafficTimeKey) {
|
||||
if (configVo.key === hysteria2TrafficTimeKey) {
|
||||
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) {
|
||||
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(() => {
|
||||
setConfig();
|
||||
if (route.query.focus === "huiHttps") {
|
||||
nextTick(() => {
|
||||
const input = huiHttpsRef.value.$el.querySelector(".el-input__inner");
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+26
-22
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
{
|
||||
|
||||
+14
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user