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())
|
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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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: "",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "Диск",
|
||||||
|
|||||||
@@ -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 }); // Индикатор загрузки
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
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,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"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user