Реализован production-hardening по fix1: env/reconfigure, IPv4-only, TLS, secrets, firewall, docs

This commit is contained in:
2026-04-26 07:27:06 +05:00
parent 2b4a45ad23
commit 3fccd5c442
109 changed files with 1773 additions and 569 deletions
+16 -8
View File
@@ -1,15 +1,16 @@
# HY2XS # HY2XS
HY2XS — двухслойный install package для развёртывания на чистом Debian 12 amd64. HY2XS — production installer/runtime-manager для развёртывания Hysteria2 + HY2XS admin на чистом Debian 12 amd64.
Состав baseline: Состав baseline:
- production builder на Debian 12 amd64 создаёт один переносимый архив; - production builder на Debian 12 amd64 создаёт один переносимый архив;
- install-only оркестратор на Bun + TypeScript поставляется на target как готовый compiled artifact; - install-only оркестратор на Bun + TypeScript поставляется на target как готовый compiled artifact;
- HY2XS admin поставляется в составе пакета как bundled fork; - HY2XS admin поставляется в составе пакета как bundled fork;
- vanilla Hysteria2 скачивается с official upstream во время установки. - vanilla Hysteria2 скачивается с official upstream во время установки;
- runtime-конфиг управляется через `/etc/hy2xs/hy2xs.env` и команду `reconfigure`.
В baseline намеренно не входят target-side build, update, rollback, uninstall, Telegram bot delivery, billing и внешний контур выдачи access/profile. В baseline намеренно не входят target-side build, rollback/uninstall orchestration, Telegram bot delivery, port hopping и generic server-manager функции.
## Структура репозитория ## Структура репозитория
@@ -51,18 +52,25 @@ https://git.ext.flamy.studio/flamy_dev/HY2XS_flamy.git
Публиковать изменения нужно только после прохождения baseline acceptance, сборки оркестратора и smoke checks итогового пакета. Публиковать изменения нужно только после прохождения baseline acceptance, сборки оркестратора и smoke checks итогового пакета.
## Установка ## Установка и конфигурация
На чистом Debian 12 target нужно распаковать архив и запустить от root: На чистом Debian 12 target нужно распаковать архив и запустить от root:
```sh ```sh
./install.sh --non-interactive --domain example.com ./install.sh --package-dir . --config /etc/hy2xs/hy2xs.env --non-interactive
``` ```
Полезные baseline-флаги: После установки применяются команды оркестратора:
```sh ```sh
./install.sh --non-interactive --domain example.com --port 443 --ssh-port 22 --ui-port 8080 hy2xs-orchestrator reconfigure --package-dir /opt/hy2xs-package --config /etc/hy2xs/hy2xs.env --dry-run
hy2xs-orchestrator reconfigure --package-dir /opt/hy2xs-package --config /etc/hy2xs/hy2xs.env --apply
``` ```
После установки создаётся `/etc/hysteria/post-install.env` с фактическими параметрами deploy-состояния. Ключевые инварианты:
- только IPv4 (`0.0.0.0:<port>` для Hysteria, `127.0.0.1:<ui_port>` для UI по умолчанию);
- UI запускается не от root (`hy2xs-admin`);
- секреты и чувствительные конфиги: `0600`;
- snapshot deploy-фактов: `/etc/hysteria/post-install.env`;
- editable runtime layer: `/etc/hy2xs/hy2xs.env`.
-1
View File
@@ -48,4 +48,3 @@ func Execute() {
os.Exit(1) os.Exit(1)
} }
} }
+5 -2
View File
@@ -36,7 +36,11 @@ func runReset(cmd *cobra.Command, args []string) {
} }
if err = dao.UpdateAccount([]int64{1}, map[string]interface{}{ if err = dao.UpdateAccount([]int64{1}, map[string]interface{}{
"username": username, "username": username,
"pass": util.SHA224String(password), "pass": func() string {
hash, _ := util.HashPassword(password)
return hash
}(),
"force_password_change": 1,
"con_pass": fmt.Sprintf("%s.%s", username, password)}); err != nil { "con_pass": fmt.Sprintf("%s.%s", username, password)}); err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
os.Exit(1) os.Exit(1)
@@ -49,4 +53,3 @@ func runReset(cmd *cobra.Command, args []string) {
fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password)) fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password))
fmt.Println(fmt.Sprintf("HY2XS admin Connection Password: %s", fmt.Sprintf("%s.%s", username, password))) fmt.Println(fmt.Sprintf("HY2XS admin Connection Password: %s", fmt.Sprintf("%s.%s", username, password)))
} }
+18 -8
View File
@@ -11,10 +11,23 @@ import (
"hy2xs-admin/router" "hy2xs-admin/router"
"hy2xs-admin/service" "hy2xs-admin/service"
"hy2xs-admin/util" "hy2xs-admin/util"
"net"
"net/http" "net/http"
"os" "os"
) )
func resolveUiBindHost() (string, error) {
host := os.Getenv("HY2XS_UI_BIND_HOST")
if host == "" {
host = "127.0.0.1"
}
ip := net.ParseIP(host)
if ip == nil || ip.To4() == nil {
return "", errors.New("HY2XS_UI_BIND_HOST must be IPv4")
}
return host, nil
}
func runServer(port string) error { func runServer(port string) error {
defer releaseResource() defer releaseResource()
@@ -32,12 +45,6 @@ func runServer(port string) error {
if err := service.InitHysteria2(); err != nil { if err := service.InitHysteria2(); err != nil {
return err return err
} }
if err := service.InitTableAndChain(); err != nil {
logrus.Errorf(err.Error())
}
if err := service.InitPortHopping(); err != nil {
logrus.Errorf(err.Error())
}
config, err := dao.GetConfig("key = ?", constant.HUIWebContext) config, err := dao.GetConfig("key = ?", constant.HUIWebContext)
if err != nil { if err != nil {
return err return err
@@ -50,8 +57,12 @@ func runServer(port string) error {
if err != nil { if err != nil {
return err return err
} }
bindHost, err := resolveUiBindHost()
if err != nil {
return err
}
service.InitServer(fmt.Sprintf(":%d", serverPort), r) service.InitServer(fmt.Sprintf("%s:%d", bindHost, serverPort), r)
if err := service.StartServer(crtPath, keyPath); err != nil && err != http.ErrServerClosed { if err := service.StartServer(crtPath, keyPath); 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")
@@ -83,4 +94,3 @@ func initFile() error {
} }
return nil return nil
} }
-1
View File
@@ -20,4 +20,3 @@ func init() {
func runVersion(cmd *cobra.Command, args []string) { func runVersion(cmd *cobra.Command, args []string) {
fmt.Println("HY2XS admin version", constant.Version) fmt.Println("HY2XS admin version", constant.Version)
} }
+14 -6
View File
@@ -26,7 +26,7 @@ func Login(c *gin.Context) {
return return
} }
token, err := service.Login(*loginDto.Username, util.SHA224String(*loginDto.Pass)) token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
@@ -34,6 +34,7 @@ func Login(c *gin.Context) {
jwtVo := vo.JwtVo{ jwtVo := vo.JwtVo{
TokenType: constant.TokenType, TokenType: constant.TokenType,
AccessToken: token, AccessToken: token,
ForcePasswordChange: forcePasswordChange,
} }
vo.Success(jwtVo, c) vo.Success(jwtVo, c)
} }
@@ -100,7 +101,11 @@ func SaveAccount(c *gin.Context) {
return return
} }
passEncrypt := util.SHA224String(*accountSaveDto.Pass) passEncrypt, err := util.HashPassword(*accountSaveDto.Pass)
if err != nil {
vo.Fail(err.Error(), c)
return
}
conPass := fmt.Sprintf("%s.%s", *accountSaveDto.Username, *accountSaveDto.ConPass) conPass := fmt.Sprintf("%s.%s", *accountSaveDto.Username, *accountSaveDto.ConPass)
account := entity.Account{ account := entity.Account{
Username: accountSaveDto.Username, Username: accountSaveDto.Username,
@@ -167,8 +172,12 @@ func UpdateAccount(c *gin.Context) {
var passEncrypt *string var passEncrypt *string
if accountUpdateDto.Pass != nil && *accountUpdateDto.Pass != "" { if accountUpdateDto.Pass != nil && *accountUpdateDto.Pass != "" {
passEncryptSha224 := util.SHA224String(*accountUpdateDto.Pass) passEncryptHash, hashErr := util.HashPassword(*accountUpdateDto.Pass)
passEncrypt = &passEncryptSha224 if hashErr != nil {
vo.Fail(hashErr.Error(), c)
return
}
passEncrypt = &passEncryptHash
} }
account := entity.Account{ account := entity.Account{
@@ -331,6 +340,5 @@ func VerifyDefaultPass(c *gin.Context) {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
vo.Success(account.Pass != nil && *account.Pass == "02f382b76ca1ab7aa06ab03345c7712fd5b971fb0c0f2aef98bac9cd", c) vo.Success(account.Pass != nil && !util.IsBcryptHash(*account.Pass), c)
} }
+15 -58
View File
@@ -17,7 +17,6 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -35,7 +34,6 @@ func UpdateConfigs(c *gin.Context) {
return return
} }
needResetPortHopping := false
needRestart := false needRestart := false
for _, item := range configsUpdateDto.ConfigUpdateDtos { for _, item := range configsUpdateDto.ConfigUpdateDtos {
@@ -81,20 +79,9 @@ func UpdateConfigs(c *gin.Context) {
} }
if key == constant.Hysteria2ConfigPortHopping { if key == constant.Hysteria2ConfigPortHopping {
re := regexp.MustCompile(`^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$`) vo.Fail("port hopping out of scope in HY2XS production", c)
if value != "" && !re.MatchString(value) {
vo.Fail(fmt.Sprintf("port hopping: %s is invalid", value), c)
return return
} }
hysteria2ConfigPortHopping, err := service.GetConfig(constant.Hysteria2ConfigPortHopping)
if err != nil {
vo.Fail(err.Error(), c)
return
}
if *hysteria2ConfigPortHopping.Value != value {
needResetPortHopping = true
}
}
if key == constant.ResetTrafficCron { if key == constant.ResetTrafficCron {
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron) resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron)
@@ -113,13 +100,6 @@ func UpdateConfigs(c *gin.Context) {
} }
} }
if needResetPortHopping {
if err := service.InitPortHopping(); err != nil {
vo.Fail(err.Error(), c)
return
}
}
if needRestart { if needRestart {
go func() { go func() {
_ = service.StopServer() _ = service.StopServer()
@@ -197,33 +177,11 @@ func UpdateHysteria2Config(c *gin.Context) {
return return
} }
hysteria2Config, err := service.GetHysteria2Config()
if err != nil {
vo.Fail(err.Error(), c)
return
}
needResetPortHopping := false
if hysteria2Config.Listen != nil &&
*hysteria2Config.Listen != "" &&
hysteria2ServerConfig.Listen != nil &&
*hysteria2ServerConfig.Listen != "" &&
*hysteria2ServerConfig.Listen != *hysteria2Config.Listen {
needResetPortHopping = true
}
if err = service.UpdateHysteria2Config(hysteria2ServerConfig); err != nil { if err = service.UpdateHysteria2Config(hysteria2ServerConfig); err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
if needResetPortHopping {
if err := service.InitPortHopping(); err != nil {
vo.Fail(err.Error(), c)
return
}
}
running := service.Hysteria2IsRunning() running := service.Hysteria2IsRunning()
if running { if running {
if err = service.RestartHysteria2(); err != nil { if err = service.RestartHysteria2(); err != nil {
@@ -242,24 +200,24 @@ func ExportHysteria2Config(c *gin.Context) {
} }
// Значения по умолчанию // Значения по умолчанию
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.JwtSecret}) config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.Hysteria2TrafficStatsSecret})
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
var hUIWebPort string var hUIWebPort string
var jwtSecret string var trafficStatsSecret string
for _, item := range config { for _, item := range config {
if *item.Key == constant.HUIWebPort { if *item.Key == constant.HUIWebPort {
hUIWebPort = *item.Value hUIWebPort = *item.Value
} else if *item.Key == constant.JwtSecret { } else if *item.Key == constant.Hysteria2TrafficStatsSecret {
jwtSecret = *item.Value trafficStatsSecret = *item.Value
} }
} }
if hUIWebPort == "" || jwtSecret == "" { if hUIWebPort == "" || trafficStatsSecret == "" {
logrus.Errorf("hUIWebPort or jwtSecret is nil") logrus.Errorf("hUIWebPort or trafficStatsSecret is nil")
vo.Fail(constant.SysError, c) vo.Fail(constant.SysError, c)
return return
} }
@@ -279,7 +237,7 @@ func ExportHysteria2Config(c *gin.Context) {
http.Insecure = &authHttpInsecure http.Insecure = &authHttpInsecure
auth.HTTP = &http auth.HTTP = &http
hysteria2ServerConfig.Auth = &auth hysteria2ServerConfig.Auth = &auth
hysteria2ServerConfig.TrafficStats.Secret = &jwtSecret hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405")) fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405"))
filePath := constant.ExportPathDir + fileName filePath := constant.ExportPathDir + fileName
@@ -325,24 +283,24 @@ func ImportHysteria2Config(c *gin.Context) {
} }
// Значения по умолчанию // Значения по умолчанию
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.JwtSecret}) config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.Hysteria2TrafficStatsSecret})
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
var hUIWebPort string var hUIWebPort string
var jwtSecret string var trafficStatsSecret string
for _, item := range config { for _, item := range config {
if *item.Key == constant.HUIWebPort { if *item.Key == constant.HUIWebPort {
hUIWebPort = *item.Value hUIWebPort = *item.Value
} else if *item.Key == constant.JwtSecret { } else if *item.Key == constant.Hysteria2TrafficStatsSecret {
jwtSecret = *item.Value trafficStatsSecret = *item.Value
} }
} }
if hUIWebPort == "" || jwtSecret == "" { if hUIWebPort == "" || trafficStatsSecret == "" {
logrus.Errorf("hUIWebPort or jwtSecret is nil") logrus.Errorf("hUIWebPort or trafficStatsSecret is nil")
vo.Fail(constant.SysError, c) vo.Fail(constant.SysError, c)
return return
} }
@@ -362,7 +320,7 @@ func ImportHysteria2Config(c *gin.Context) {
http.Insecure = &authHttpInsecure http.Insecure = &authHttpInsecure
auth.HTTP = &http auth.HTTP = &http
hysteria2ServerConfig.Auth = &auth hysteria2ServerConfig.Auth = &auth
hysteria2ServerConfig.TrafficStats.Secret = &jwtSecret hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
if err = service.SetHysteria2Config(hysteria2ServerConfig); err != nil { if err = service.SetHysteria2Config(hysteria2ServerConfig); err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
@@ -501,4 +459,3 @@ func UploadCertFile(c *gin.Context) {
} }
vo.Success(certPath, c) vo.Success(certPath, c)
} }
-1
View File
@@ -150,4 +150,3 @@ func Hysteria2Subscribe(c *gin.Context) {
c.String(200, configStr) c.String(200, configStr)
} }
-1
View File
@@ -114,4 +114,3 @@ func ExportLog(c *gin.Context) {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath) c.File(filePath)
} }
-1
View File
@@ -23,4 +23,3 @@ func MonitorHysteria2(c *gin.Context) {
} }
vo.Success(hysteria2MonitorVo, c) vo.Success(hysteria2MonitorVo, c)
} }
-1
View File
@@ -39,4 +39,3 @@ func validateField[T interface{}](c *gin.Context, field T) (T, error) {
} }
return field, nil return field, nil
} }
-1
View File
@@ -117,4 +117,3 @@ func ListAccount(query interface{}, args ...interface{}) ([]entity.Account, erro
} }
return accounts, nil return accounts, nil
} }
-1
View File
@@ -64,4 +64,3 @@ func UpsertConfig(configs []entity.Config) error {
} }
return nil return nil
} }
+62 -1
View File
@@ -9,6 +9,8 @@ import (
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"gorm.io/gorm/schema" "gorm.io/gorm/schema"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/util"
"log" "log"
"os" "os"
"strings" "strings"
@@ -69,6 +71,66 @@ func InitSql(port string) error {
} }
} }
} }
if err := ensureSecureBootstrapAdmin(); err != nil {
return err
}
if err := ensureTrafficStatsSecret(); err != nil {
return err
}
if tx := sqliteDB.Exec("ALTER TABLE account ADD COLUMN force_password_change INTEGER NOT NULL DEFAULT 0"); tx.Error != nil && !strings.Contains(tx.Error.Error(), "duplicate column name") {
logrus.Errorf("sqlite exec err: %v", tx.Error)
return errors.New("sqlite exec err")
}
return nil
}
func ensureSecureBootstrapAdmin() error {
admin, err := GetAccount("role = 'admin' and deleted = 0")
if err != nil {
return nil
}
if admin.Pass == nil {
return nil
}
if !util.IsBcryptHash(*admin.Pass) {
password, pwdErr := util.RandomString(18)
if pwdErr != nil {
return pwdErr
}
hash, hashErr := util.HashPassword(password)
if hashErr != nil {
return hashErr
}
conPass, conErr := util.RandomString(28)
if conErr != nil {
return conErr
}
if updateErr := UpdateAccount([]int64{*admin.Id}, map[string]interface{}{
"pass": hash,
"force_password_change": 1,
"con_pass": fmt.Sprintf("%s.%s", *admin.Username, conPass),
}); updateErr != nil {
return updateErr
}
}
return nil
}
func ensureTrafficStatsSecret() error {
if _, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
return nil
}
secret, secErr := util.RandomString(32)
if secErr != nil {
return secErr
}
key := constant.Hysteria2TrafficStatsSecret
remark := "Hysteria2 trafficStats API secret"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil {
return saveErr
}
return nil return nil
} }
@@ -117,4 +179,3 @@ func Paginate(pageNum *int64, pageSize *int64) func(db *gorm.DB) *gorm.DB {
return db.Offset(int((num - 1) * size)).Limit(int(size)) return db.Offset(int((num - 1) * size)).Limit(int(size))
} }
} }
-1
View File
@@ -73,4 +73,3 @@ func replaceRelativePaths(htmlContent string, basePath string) string {
</script>`, basePath) </script>`, basePath)
return strings.Replace(htmlContent, "</head>", injection+"</head>", 1) return strings.Replace(htmlContent, "</head>", injection+"</head>", 1)
} }
-3
View File
@@ -16,13 +16,10 @@
"@vueuse/core": "^9.1.1", "@vueuse/core": "^9.1.1",
"axios": "^1.3.4", "axios": "^1.3.4",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"echarts": "^5.2.2",
"element-plus": "^2.3.1", "element-plus": "^2.3.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"path-to-regexp": "^6.2.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"screenfull": "^6.0.0",
"vue": "^3.2.45", "vue": "^3.2.45",
"vue-i18n": "9", "vue-i18n": "9",
"vue-router": "^4.1.6", "vue-router": "^4.1.6",
+2 -58
View File
@@ -20,9 +20,6 @@ importers:
copy-to-clipboard: copy-to-clipboard:
specifier: ^3.3.3 specifier: ^3.3.3
version: 3.3.3 version: 3.3.3
echarts:
specifier: ^5.2.2
version: 5.2.2
element-plus: element-plus:
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.3.1(vue@3.2.45) version: 2.3.1(vue@3.2.45)
@@ -32,15 +29,9 @@ importers:
path-browserify: path-browserify:
specifier: ^1.0.1 specifier: ^1.0.1
version: 1.0.1 version: 1.0.1
path-to-regexp:
specifier: ^6.2.0
version: 6.2.0
pinia: pinia:
specifier: ^2.0.33 specifier: ^2.0.33
version: 2.0.33(typescript@4.9.3)(vue@3.2.45) version: 2.0.33(typescript@4.9.3)(vue@3.2.45)
screenfull:
specifier: ^6.0.0
version: 6.0.0
vue: vue:
specifier: ^3.2.45 specifier: ^3.2.45
version: 3.2.45 version: 3.2.45
@@ -89,7 +80,7 @@ importers:
version: 9.9.0(eslint@8.34.0) version: 9.9.0(eslint@8.34.0)
fast-glob: fast-glob:
specifier: ^3.2.11 specifier: ^3.2.11
version: 3.2.11 version: 3.3.1
postcss: postcss:
specifier: ^8.4.21 specifier: ^8.4.21
version: 8.4.21 version: 8.4.21
@@ -559,28 +550,24 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.11.24': '@swc/core-linux-arm64-musl@1.11.24':
resolution: {integrity: sha512-ypXLIdszRo0re7PNNaXN0+2lD454G8l9LPK/rbfRXnhLWDBPURxzKlLlU/YGd2zP98wPcVooMmegRSNOKfvErw==} resolution: {integrity: sha512-ypXLIdszRo0re7PNNaXN0+2lD454G8l9LPK/rbfRXnhLWDBPURxzKlLlU/YGd2zP98wPcVooMmegRSNOKfvErw==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@swc/core-linux-x64-gnu@1.11.24': '@swc/core-linux-x64-gnu@1.11.24':
resolution: {integrity: sha512-IM7d+STVZD48zxcgo69L0yYptfhaaE9cMZ+9OoMxirNafhKKXwoZuufol1+alEFKc+Wbwp+aUPe/DeWC/Lh3dg==} resolution: {integrity: sha512-IM7d+STVZD48zxcgo69L0yYptfhaaE9cMZ+9OoMxirNafhKKXwoZuufol1+alEFKc+Wbwp+aUPe/DeWC/Lh3dg==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.11.24': '@swc/core-linux-x64-musl@1.11.24':
resolution: {integrity: sha512-DZByJaMVzSfjQKKQn3cqSeqwy6lpMaQDQQ4HPlch9FWtDx/dLcpdIhxssqZXcR2rhaQVIaRQsCqwV6orSDGAGw==} resolution: {integrity: sha512-DZByJaMVzSfjQKKQn3cqSeqwy6lpMaQDQQ4HPlch9FWtDx/dLcpdIhxssqZXcR2rhaQVIaRQsCqwV6orSDGAGw==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.11.24': '@swc/core-win32-arm64-msvc@1.11.24':
resolution: {integrity: sha512-Q64Ytn23y9aVDKN5iryFi8mRgyHw3/kyjTjT4qFCa8AEb5sGUuSj//AUZ6c0J7hQKMHlg9do5Etvoe61V98/JQ==} resolution: {integrity: sha512-Q64Ytn23y9aVDKN5iryFi8mRgyHw3/kyjTjT4qFCa8AEb5sGUuSj//AUZ6c0J7hQKMHlg9do5Etvoe61V98/JQ==}
@@ -1273,9 +1260,6 @@ packages:
duplexer@0.1.2: duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
echarts@5.2.2:
resolution: {integrity: sha512-yxuBfeIH5c+0FsoRP60w4De6omXhA06c7eUYBsC1ykB6Ys2yK5fSteIYWvkJ4xJVLQgCvAdO8C4mN6MLeJpBaw==}
electron-to-chromium@1.4.529: electron-to-chromium@1.4.529:
resolution: {integrity: sha512-6uyPyXTo8lkv8SWAmjKFbG42U073TXlzD4R8rW3EzuznhFS2olCIAfjjQtV2dV2ar/vRF55KUd3zQYnCB0dd3A==} resolution: {integrity: sha512-6uyPyXTo8lkv8SWAmjKFbG42U073TXlzD4R8rW3EzuznhFS2olCIAfjjQtV2dV2ar/vRF55KUd3zQYnCB0dd3A==}
@@ -1446,10 +1430,6 @@ packages:
fast-diff@1.3.0: fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
fast-glob@3.2.11:
resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==}
engines: {node: '>=8.6.0'}
fast-glob@3.3.1: fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'} engines: {node: '>=8.6.0'}
@@ -2221,9 +2201,6 @@ packages:
path-parse@1.0.7: path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-to-regexp@6.2.0:
resolution: {integrity: sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==}
path-type@4.0.0: path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2466,10 +2443,6 @@ packages:
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
hasBin: true hasBin: true
screenfull@6.0.0:
resolution: {integrity: sha512-LGY0nhNQkC4FX4DT4pZdJ5cZH5EOz9Gfh9KcVMl779pS677k4IV1Wv7sY/CwC9VKFT21fYgCh7zkTVVefi5XKA==}
engines: {node: ^14.13.1 || >=16.0.0}
scule@1.0.0: scule@1.0.0:
resolution: {integrity: sha512-4AsO/FrViE/iDNEPaAQlb77tf0csuq27EsVpy6ett584EcRTp6pTDLoGWVxCD77y5iU5FauOvhsI4o1APwPoSQ==} resolution: {integrity: sha512-4AsO/FrViE/iDNEPaAQlb77tf0csuq27EsVpy6ett584EcRTp6pTDLoGWVxCD77y5iU5FauOvhsI4o1APwPoSQ==}
@@ -2796,9 +2769,6 @@ packages:
tslib@1.14.1: tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tsutils@3.21.0: tsutils@3.21.0:
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -3055,9 +3025,6 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zrender@5.2.1:
resolution: {integrity: sha512-M3bPGZuyLTNBC6LiNKXJwSCtglMp8XUEqEBG+2MdICDI3d1s500Y4P0CzldQGsqpRVB7fkvf3BKQQRxsEaTlsw==}
snapshots: snapshots:
'@aashutoshrathi/word-wrap@1.2.6': {} '@aashutoshrathi/word-wrap@1.2.6': {}
@@ -4313,11 +4280,6 @@ snapshots:
duplexer@0.1.2: {} duplexer@0.1.2: {}
echarts@5.2.2:
dependencies:
tslib: 2.3.0
zrender: 5.2.1
electron-to-chromium@1.4.529: {} electron-to-chromium@1.4.529: {}
element-plus@2.3.1(vue@3.2.45): element-plus@2.3.1(vue@3.2.45):
@@ -4566,14 +4528,6 @@ snapshots:
fast-diff@1.3.0: {} fast-diff@1.3.0: {}
fast-glob@3.2.11:
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.5
fast-glob@3.3.1: fast-glob@3.3.1:
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -4715,7 +4669,7 @@ snapshots:
dependencies: dependencies:
array-union: 2.1.0 array-union: 2.1.0
dir-glob: 3.0.1 dir-glob: 3.0.1
fast-glob: 3.2.11 fast-glob: 3.3.1
ignore: 5.2.4 ignore: 5.2.4
merge2: 1.4.1 merge2: 1.4.1
slash: 3.0.0 slash: 3.0.0
@@ -5312,8 +5266,6 @@ snapshots:
path-parse@1.0.7: {} path-parse@1.0.7: {}
path-to-regexp@6.2.0: {}
path-type@4.0.0: {} path-type@4.0.0: {}
pathe@0.2.0: {} pathe@0.2.0: {}
@@ -5542,8 +5494,6 @@ snapshots:
immutable: 4.3.4 immutable: 4.3.4
source-map-js: 1.0.2 source-map-js: 1.0.2
screenfull@6.0.0: {}
scule@1.0.0: {} scule@1.0.0: {}
semver@5.7.2: {} semver@5.7.2: {}
@@ -5922,8 +5872,6 @@ snapshots:
tslib@1.14.1: {} tslib@1.14.1: {}
tslib@2.3.0: {}
tsutils@3.21.0(typescript@4.9.3): tsutils@3.21.0(typescript@4.9.3):
dependencies: dependencies:
tslib: 1.14.1 tslib: 1.14.1
@@ -6226,7 +6174,3 @@ snapshots:
yargs-parser@20.2.9: {} yargs-parser@20.2.9: {}
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zrender@5.2.1:
dependencies:
tslib: 2.3.0
+1 -3
View File
@@ -8,12 +8,12 @@ require (
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-playground/validator/v10 v10.14.0 github.com/go-playground/validator/v10 v10.14.0
github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/go-github/v39 v39.2.0
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil v3.21.11+incompatible github.com/shirou/gopsutil v3.21.11+incompatible
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.18.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.25.9 gorm.io/gorm v1.25.9
@@ -32,7 +32,6 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect github.com/google/go-cmp v0.5.9 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
@@ -55,7 +54,6 @@ require (
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/net v0.20.0 // indirect golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect
-22
View File
@@ -38,18 +38,10 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
@@ -129,35 +121,21 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-1
View File
@@ -5,4 +5,3 @@ import "hy2xs-admin/cmd"
func main() { func main() {
cmd.Execute() cmd.Execute()
} }
-1
View File
@@ -24,4 +24,3 @@ func AdminHandler() gin.HandlerFunc {
c.Next() c.Next()
} }
} }
-1
View File
@@ -31,4 +31,3 @@ func InitCron() error {
c.Start() c.Start()
return nil return nil
} }
-1
View File
@@ -23,4 +23,3 @@ func FilterHandler() gin.HandlerFunc {
c.Next() c.Next()
} }
} }
-1
View File
@@ -36,4 +36,3 @@ func JWTHandler() gin.HandlerFunc {
c.Next() c.Next()
} }
} }
-1
View File
@@ -41,4 +41,3 @@ func LogHandler() gin.HandlerFunc {
c.Next() c.Next()
} }
} }
-1
View File
@@ -24,4 +24,3 @@ func RateLimiterHandler() gin.HandlerFunc {
c.Next() c.Next()
} }
} }
-1
View File
@@ -28,4 +28,3 @@ type AccountExport struct {
ConAt int64 `json:"conAt"` ConAt int64 `json:"conAt"`
Remark string `json:"remark"` Remark string `json:"remark"`
} }
-1
View File
@@ -203,4 +203,3 @@ type serverConfigMasquerade struct {
ListenHTTPS *string `yaml:"listenHTTPS,omitempty" json:"listenHTTPS" validate:"omitempty"` ListenHTTPS *string `yaml:"listenHTTPS,omitempty" json:"listenHTTPS" validate:"omitempty"`
ForceHTTPS *bool `yaml:"forceHTTPS,omitempty" json:"forceHTTPS" validate:"omitempty"` ForceHTTPS *bool `yaml:"forceHTTPS,omitempty" json:"forceHTTPS" validate:"omitempty"`
} }
-1
View File
@@ -4,4 +4,3 @@ type Hysteria2UserTraffic struct {
Tx int64 `json:"tx"` // upload Tx int64 `json:"tx"` // upload
Rx int64 `json:"rx"` // download Rx int64 `json:"rx"` // download
} }
-1
View File
@@ -25,4 +25,3 @@ type ClashConfig struct {
Proxies []interface{} `yaml:"proxies"` Proxies []interface{} `yaml:"proxies"`
ProxyGroups []ProxyGroup `yaml:"proxy-groups"` ProxyGroups []ProxyGroup `yaml:"proxy-groups"`
} }
-1
View File
@@ -6,4 +6,3 @@ const (
V2rayN = "v2rayn" V2rayN = "v2rayn"
NekoBox = "nekobox" NekoBox = "nekobox"
) )
-1
View File
@@ -7,4 +7,3 @@ const (
CodeForbiddenError int = 50403 CodeForbiddenError int = 50403
CodeInvalidError int = 50001 CodeInvalidError int = 50001
) )
+1 -1
View File
@@ -6,6 +6,7 @@ const (
HUICrtPath = "H_UI_CRT_PATH" HUICrtPath = "H_UI_CRT_PATH"
HUIKeyPath = "H_UI_KEY_PATH" HUIKeyPath = "H_UI_KEY_PATH"
JwtSecret = "JWT_SECRET" JwtSecret = "JWT_SECRET"
Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET"
Hysteria2Enable = "HYSTERIA2_ENABLE" Hysteria2Enable = "HYSTERIA2_ENABLE"
Hysteria2Config = "HYSTERIA2_CONFIG" Hysteria2Config = "HYSTERIA2_CONFIG"
Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME" Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME"
@@ -14,4 +15,3 @@ const (
ResetTrafficCron = "RESET_TRAFFIC_CRON" ResetTrafficCron = "RESET_TRAFFIC_CRON"
ClashExtension = "CLASH_EXTENSION" ClashExtension = "CLASH_EXTENSION"
) )
-1
View File
@@ -12,4 +12,3 @@ const (
WrongPassword string = "wrong password" WrongPassword string = "wrong password"
ConfigNotExist string = "config not exist" ConfigNotExist string = "config not exist"
) )
-1
View File
@@ -18,4 +18,3 @@ const (
Version = "v0.0.22" Version = "v0.0.22"
) )
-1
View File
@@ -34,4 +34,3 @@ type AccountUpdateDto struct {
Deleted *int64 `json:"deleted" form:"deleted" validate:"omitempty,oneof=0 1"` Deleted *int64 `json:"deleted" form:"deleted" validate:"omitempty,oneof=0 1"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=32"` Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=32"`
} }
-1
View File
@@ -16,4 +16,3 @@ type ConfigUpdateDto struct {
type ConfigsUpdateDto struct { type ConfigsUpdateDto struct {
ConfigUpdateDtos []ConfigUpdateDto `json:"configUpdateDtos" form:"configUpdateDtos" validate:"required"` ConfigUpdateDtos []ConfigUpdateDto `json:"configUpdateDtos" form:"configUpdateDtos" validate:"required"`
} }
-1
View File
@@ -10,4 +10,3 @@ type BaseDto struct {
type IdDto struct { type IdDto struct {
Id *int64 `json:"id" form:"id" validate:"required,gt=0"` // Первичный ключ Id *int64 `json:"id" form:"id" validate:"required,gt=0"` // Первичный ключ
} }
-1
View File
@@ -25,4 +25,3 @@ type Hysteria2UrlDto struct {
AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"` AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"`
Hostname *string `json:"hostname" form:"hostname" validate:"required,min=1,max=255"` Hostname *string `json:"hostname" form:"hostname" validate:"required,min=1,max=255"`
} }
-1
View File
@@ -7,4 +7,3 @@ type LogDto struct {
type LogExportDto struct { type LogExportDto struct {
Option *int `json:"option" form:"option" validate:"required,oneof=0 1"` Option *int `json:"option" form:"option" validate:"required,oneof=0 1"`
} }
-1
View File
@@ -3,4 +3,3 @@ package dto
type ServerDto struct { type ServerDto struct {
Port *int64 `json:"port" form:"port" validate:"required,min=1,max=65535"` Port *int64 `json:"port" form:"port" validate:"required,min=1,max=65535"`
} }
+1 -1
View File
@@ -17,5 +17,5 @@ type Account struct {
LoginAt *int64 `gorm:"column:login_at;default:0" json:"loginAt"` LoginAt *int64 `gorm:"column:login_at;default:0" json:"loginAt"`
ConAt *int64 `gorm:"column:con_at;default:0" json:"conAt"` ConAt *int64 `gorm:"column:con_at;default:0" json:"conAt"`
Remark *string `gorm:"column:remark;default:''" json:"remark"` Remark *string `gorm:"column:remark;default:''" json:"remark"`
ForcePasswordChange *int64 `gorm:"column:force_password_change;default:0" json:"forcePasswordChange"`
} }
-1
View File
@@ -6,4 +6,3 @@ type Config struct {
Remark *string `gorm:"column:remark;default:''" json:"remark"` Remark *string `gorm:"column:remark;default:''" json:"remark"`
BaseEntity `gorm:"embedded"` BaseEntity `gorm:"embedded"`
} }
-1
View File
@@ -7,4 +7,3 @@ type BaseEntity struct {
CreateTime *time.Time `gorm:"column:create_time;default:null" json:"createTime"` CreateTime *time.Time `gorm:"column:create_time;default:null" json:"createTime"`
UpdateTime *time.Time `gorm:"column:update_time;default:null" json:"updateTime"` UpdateTime *time.Time `gorm:"column:update_time;default:null" json:"updateTime"`
} }
-1
View File
@@ -29,4 +29,3 @@ type AccountInfoVo struct {
Username string `json:"username"` Username string `json:"username"`
Roles []string `json:"roles"` Roles []string `json:"roles"`
} }
-1
View File
@@ -5,4 +5,3 @@ type ConfigVo struct {
Value string `json:"value"` Value string `json:"value"`
Remark string `json:"remark"` Remark string `json:"remark"`
} }
-1
View File
@@ -38,4 +38,3 @@ type Hysteria2AcmePathVo struct {
CrtPath string `json:"crtPath"` CrtPath string `json:"crtPath"`
KeyPath string `json:"keyPath"` KeyPath string `json:"keyPath"`
} }
+1 -1
View File
@@ -3,5 +3,5 @@ package vo
type JwtVo struct { type JwtVo struct {
TokenType string `json:"tokenType"` TokenType string `json:"tokenType"`
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
ForcePasswordChange bool `json:"forcePasswordChange"`
} }
-1
View File
@@ -21,4 +21,3 @@ type LogHysteria2Vo struct {
Msg string `json:"msg"` Msg string `json:"msg"`
Time string `json:"time"` Time string `json:"time"`
} }
-1
View File
@@ -13,4 +13,3 @@ type Hysteria2MonitorVo struct {
Version string `json:"version"` // Версия Version string `json:"version"` // Версия
Running bool `json:"running"` // Статус выполнения Running bool `json:"running"` // Статус выполнения
} }
-1
View File
@@ -44,4 +44,3 @@ func Fail(message string, c *gin.Context) {
Data: nil, Data: nil,
}) })
} }
-1
View File
@@ -6,4 +6,3 @@ type BaseVo struct {
Id int64 `json:"id"` Id int64 `json:"id"`
CreateTime time.Time `json:"createTime"` CreateTime time.Time `json:"createTime"`
} }
-1
View File
@@ -56,4 +56,3 @@ func (h *Hysteria2Process) Release() error {
} }
return nil return nil
} }
-1
View File
@@ -134,4 +134,3 @@ func (h *Hysteria2Api) OnlineUsers(secret string) (map[string]int64, error) {
} }
return onlineUsers, nil return onlineUsers, nil
} }
-1
View File
@@ -227,4 +227,3 @@ func (p *process) handleLogs(stdout, stderr io.ReadCloser) {
} }
} }
} }
-1
View File
@@ -21,4 +21,3 @@ func initAccountAdminRouter(accountApi *gin.RouterGroup) {
account.GET("/verifyDefaultPass", controller.VerifyDefaultPass) account.GET("/verifyDefaultPass", controller.VerifyDefaultPass)
} }
} }
-1
View File
@@ -11,4 +11,3 @@ func initAuthRouter(authApi *gin.RouterGroup) {
auth.POST("/login", controller.Login) auth.POST("/login", controller.Login)
} }
} }
-1
View File
@@ -22,4 +22,3 @@ func initConfigRouter(configApi *gin.RouterGroup) {
config.POST("/uploadCertFile", controller.UploadCertFile) config.POST("/uploadCertFile", controller.UploadCertFile)
} }
} }
-1
View File
@@ -24,4 +24,3 @@ func initHysteria2Router(hysteria2Api *gin.RouterGroup) {
hysteria2.GET("/hysteria2Url", controller.Hysteria2Url) hysteria2.GET("/hysteria2Url", controller.Hysteria2Url)
} }
} }
-1
View File
@@ -13,4 +13,3 @@ func initLogRouter(accountApi *gin.RouterGroup) {
account.POST("/exportLog", controller.ExportLog) account.POST("/exportLog", controller.ExportLog)
} }
} }
-1
View File
@@ -12,4 +12,3 @@ func initMonitorRouter(accountApi *gin.RouterGroup) {
account.GET("/monitorHysteria2", controller.MonitorHysteria2) account.GET("/monitorHysteria2", controller.MonitorHysteria2)
} }
} }
-1
View File
@@ -39,4 +39,3 @@ func Router(router *gin.Engine, huiWebContext *string) {
} }
} }
} }
+24 -5
View File
@@ -10,20 +10,40 @@ import (
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/util"
) )
func Login(username string, pass string) (string, error) { func Login(username string, plainPassword string) (string, bool, error) {
account, err := dao.GetAccount("username = ? and pass = ? and role = 'admin' and deleted = 0", username, pass) account, err := dao.GetAccount("username = ? and role = 'admin' and deleted = 0", username)
if err != nil { if err != nil {
return "", err return "", false, err
} }
verified, legacy := util.VerifyPassword(plainPassword, *account.Pass)
if !verified {
return "", false, errors.New(constant.WrongPassword)
}
if legacy {
hash, hashErr := util.HashPassword(plainPassword)
if hashErr == nil {
_ = dao.UpdateAccount([]int64{*account.Id}, map[string]interface{}{"pass": hash})
}
}
accountBo := bo.AccountBo{ accountBo := bo.AccountBo{
Id: *account.Id, Id: *account.Id,
Username: *account.Username, Username: *account.Username,
Roles: []string{*account.Role}, Roles: []string{*account.Role},
Deleted: *account.Deleted, Deleted: *account.Deleted,
} }
return GenToken(accountBo) token, tokenErr := GenToken(accountBo)
if tokenErr != nil {
return "", false, tokenErr
}
requirePasswordChange := legacy
return token, requirePasswordChange, nil
} }
func PageAccount(accountPageDto dto.AccountPageDto) ([]entity.Account, int64, error) { func PageAccount(accountPageDto dto.AccountPageDto) ([]entity.Account, int64, error) {
@@ -156,4 +176,3 @@ func GetAccountInfo(c *gin.Context) (vo.AccountInfoVo, error) {
Roles: myClaims.AccountBo.Roles, Roles: myClaims.AccountBo.Roles,
}, nil }, nil
} }
+7 -8
View File
@@ -74,23 +74,23 @@ func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
func UpdateHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error { func UpdateHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error {
// Значения по умолчанию // Значения по умолчанию
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.JwtSecret}) config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.Hysteria2TrafficStatsSecret})
if err != nil { if err != nil {
return err return err
} }
var hUIWebPort string var hUIWebPort string
var jwtSecret string var trafficStatsSecret string
for _, item := range config { for _, item := range config {
if *item.Key == constant.HUIWebPort { if *item.Key == constant.HUIWebPort {
hUIWebPort = *item.Value hUIWebPort = *item.Value
} else if *item.Key == constant.JwtSecret { } else if *item.Key == constant.Hysteria2TrafficStatsSecret {
jwtSecret = *item.Value trafficStatsSecret = *item.Value
} }
} }
if hUIWebPort == "" || jwtSecret == "" { if hUIWebPort == "" || trafficStatsSecret == "" {
logrus.Errorf("hUIWebPort or jwtSecret is nil") logrus.Errorf("hUIWebPort or trafficStatsSecret is nil")
return errors.New(constant.SysError) return errors.New(constant.SysError)
} }
@@ -108,7 +108,7 @@ func UpdateHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error
http.Insecure = &authHttpInsecure http.Insecure = &authHttpInsecure
auth.HTTP = &http auth.HTTP = &http
hysteria2ServerConfig.Auth = &auth hysteria2ServerConfig.Auth = &auth
hysteria2ServerConfig.TrafficStats.Secret = &jwtSecret hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
yamlConfig, err := yaml.Marshal(&hysteria2ServerConfig) yamlConfig, err := yaml.Marshal(&hysteria2ServerConfig)
if err != nil { if err != nil {
@@ -195,4 +195,3 @@ func GetAuthHttpUrl() (string, error) {
} }
return fmt.Sprintf("%s://127.0.0.1:%d%s/hui/hysteria2/auth", protocol, port, webContext), nil return fmt.Sprintf("%s://127.0.0.1:%d%s/hui/hysteria2/auth", protocol, port, webContext), nil
} }
+8 -9
View File
@@ -27,16 +27,16 @@ func CronHandleAccount() {
return return
} }
jwtSecretConfig, err := dao.GetConfig("key = ?", constant.JwtSecret) trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil { if err != nil {
return return
} }
// Сохранение данных трафика // Сохранение данных трафика
go saveAccountTraffic(apiPort, *jwtSecretConfig.Value) go saveAccountTraffic(apiPort, *trafficSecretConfig.Value)
// Принудительное отключение // Принудительное отключение
go kickAccount(apiPort, *jwtSecretConfig.Value) go kickAccount(apiPort, *trafficSecretConfig.Value)
} }
}() }()
} }
@@ -58,7 +58,7 @@ func CronResetTraffic() {
} }
} }
func saveAccountTraffic(apiPort int64, jwtSecret string) { func saveAccountTraffic(apiPort int64, trafficStatsSecret string) {
if !trafficMutex.TryLock() { if !trafficMutex.TryLock() {
return return
} }
@@ -74,7 +74,7 @@ func saveAccountTraffic(apiPort int64, jwtSecret string) {
return return
} }
users, err := proxy.NewHysteria2Api(apiPort).ListUsers(true, jwtSecret) users, err := proxy.NewHysteria2Api(apiPort).ListUsers(true, trafficStatsSecret)
if err != nil { if err != nil {
return return
} }
@@ -96,13 +96,13 @@ func saveAccountTraffic(apiPort int64, jwtSecret string) {
} }
} }
func kickAccount(apiPort int64, jwtSecret string) { func kickAccount(apiPort int64, trafficStatsSecret string) {
if !kickMutex.TryLock() { if !kickMutex.TryLock() {
return return
} }
defer kickMutex.Unlock() defer kickMutex.Unlock()
users, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(jwtSecret) users, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(trafficStatsSecret)
if err != nil { if err != nil {
return return
} }
@@ -130,7 +130,7 @@ func kickAccount(apiPort int64, jwtSecret string) {
kickUsernames[j] = *item.Username kickUsernames[j] = *item.Username
j++ j++
} }
if err = proxy.NewHysteria2Api(apiPort).KickUsers(kickUsernames, jwtSecret); err != nil { if err = proxy.NewHysteria2Api(apiPort).KickUsers(kickUsernames, trafficStatsSecret); err != nil {
return return
} }
}(usernameList) }(usernameList)
@@ -138,4 +138,3 @@ func kickAccount(apiPort int64, jwtSecret string) {
wg.Wait() wg.Wait()
} }
} }
-1
View File
@@ -232,4 +232,3 @@ func iptablesRules(protocol string) ([]string, error) {
rules := strings.Split(output, "\n") rules := strings.Split(output, "\n")
return rules, nil return rules, nil
} }
+18 -2
View File
@@ -65,16 +65,33 @@ func setHysteria2ConfigYAML() error {
logrus.Errorf("marshal hysteria2 config err: %v", err) logrus.Errorf("marshal hysteria2 config err: %v", err)
return errors.New("marshal hysteria2 config err") return errors.New("marshal hysteria2 config err")
} }
file, err := os.OpenFile(constant.Hysteria2ConfigPath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) tmpPath := fmt.Sprintf("%s.tmp", constant.Hysteria2ConfigPath)
file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0600)
if err != nil { if err != nil {
logrus.Errorf("create hysteria2 server config file err: %v", err) logrus.Errorf("create hysteria2 server config file err: %v", err)
return errors.New("create hysteria2 server config file err") return errors.New("create hysteria2 server config file err")
} }
defer file.Close()
_, err = file.WriteString(string(hysteria2Config)) _, err = file.WriteString(string(hysteria2Config))
if err != nil { if err != nil {
logrus.Errorf("write hysteria2 config.json file err: %v", err) logrus.Errorf("write hysteria2 config.json file err: %v", err)
return errors.New("hysteria2 config.json file write err") return errors.New("hysteria2 config.json file write err")
} }
if syncErr := file.Sync(); syncErr != nil {
return syncErr
}
if closeErr := file.Close(); closeErr != nil {
return closeErr
}
if renameErr := os.Rename(tmpPath, constant.Hysteria2ConfigPath); renameErr != nil {
return renameErr
}
if chmodErr := os.Chmod(constant.Hysteria2ConfigPath, 0600); chmodErr != nil {
return chmodErr
}
if chownErr := os.Chown(constant.Hysteria2ConfigPath, 0, 0); chownErr != nil {
return chownErr
}
return nil return nil
} }
@@ -147,4 +164,3 @@ func Hysteria2AcmePath() (vo.Hysteria2AcmePathVo, error) {
} }
return vo.Hysteria2AcmePathVo{}, errors.New("cert not found") return vo.Hysteria2AcmePathVo{}, errors.New("cert not found")
} }
+35 -22
View File
@@ -8,11 +8,28 @@ import (
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/proxy" "hy2xs-admin/proxy"
"net"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "time"
) )
func parseListenPort(listen string) (int, error) {
host, port, err := net.SplitHostPort(listen)
if err != nil {
return 0, err
}
if host == "" || port == "" {
return 0, errors.New("invalid listen address")
}
value, convErr := strconv.Atoi(port)
if convErr != nil || value < 1 || value > 65535 {
return 0, errors.New("invalid listen port")
}
return value, nil
}
func Hysteria2Auth(conPass string) (int64, string, error) { func Hysteria2Auth(conPass string) (int64, string, error) {
if !Hysteria2IsRunning() { if !Hysteria2IsRunning() {
return 0, "", errors.New("hysteria2 is not running") return 0, "", errors.New("hysteria2 is not running")
@@ -45,11 +62,11 @@ func Hysteria2Online() (map[string]int64, error) {
if err != nil { if err != nil {
return nil, errors.New("get hysteria2 apiPort err") return nil, errors.New("get hysteria2 apiPort err")
} }
jwtSecretConfig, err := dao.GetConfig("key = ?", constant.JwtSecret) trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil { if err != nil {
return nil, err return nil, err
} }
onlineUsers, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(*jwtSecretConfig.Value) onlineUsers, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(*trafficSecretConfig.Value)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -76,11 +93,11 @@ func Hysteria2Kick(ids []int64, kickUtilTime int64) error {
if err != nil { if err != nil {
return errors.New("get hysteria2 apiPort err") return errors.New("get hysteria2 apiPort err")
} }
jwtSecretConfig, err := dao.GetConfig("key = ?", constant.JwtSecret) trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil { if err != nil {
return err return err
} }
if err = proxy.NewHysteria2Api(apiPort).KickUsers(keys, *jwtSecretConfig.Value); err != nil { if err = proxy.NewHysteria2Api(apiPort).KickUsers(keys, *trafficSecretConfig.Value); err != nil {
return err return err
} }
return nil return nil
@@ -125,13 +142,13 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string,
hysteria2Name = *hysteria2ConfigRemark.Value hysteria2Name = *hysteria2ConfigRemark.Value
} }
hysteria2ConfigPortHopping, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigPortHopping) userInfo := ""
configStr := ""
listenPort, err := parseListenPort(*hysteria2Config.Listen)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
publicHost := strings.Split(host, ":")[0]
userInfo := ""
configStr := ""
if clientType == constant.Shadowrocket || clientType == constant.Clash { if clientType == constant.Shadowrocket || clientType == constant.Clash {
userInfo = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", userInfo = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d",
*account.Upload, *account.Upload,
@@ -142,9 +159,8 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string,
hysteria2 := bo.Hysteria2{ hysteria2 := bo.Hysteria2{
Name: hysteria2Name, Name: hysteria2Name,
Type: "hysteria2", Type: "hysteria2",
Server: strings.Split(host, ":")[0], Server: publicHost,
Port: strings.Split(*hysteria2Config.Listen, ":")[1], Port: strconv.Itoa(listenPort),
Ports: *hysteria2ConfigPortHopping.Value,
Password: conPass, Password: conPass,
} }
@@ -226,6 +242,13 @@ func Hysteria2Url(accountId int64, hostname string) (string, error) {
if hysteria2Config.Listen == nil || *hysteria2Config.Listen == "" { if hysteria2Config.Listen == nil || *hysteria2Config.Listen == "" {
return "", errors.New("hysteria2 config is empty") return "", errors.New("hysteria2 config is empty")
} }
port, err := parseListenPort(*hysteria2Config.Listen)
if err != nil {
return "", err
}
if hostname == "" || hostname == "0.0.0.0" {
return "", errors.New("invalid public host")
}
account, err := dao.GetAccount("id = ?", accountId) account, err := dao.GetAccount("id = ?", accountId)
if err != nil { if err != nil {
@@ -259,15 +282,6 @@ func Hysteria2Url(accountId int64, hostname string) (string, error) {
urlConfig += fmt.Sprintf("&downmbps=%s", url.PathEscape(*hysteria2Config.Bandwidth.Down)) urlConfig += fmt.Sprintf("&downmbps=%s", url.PathEscape(*hysteria2Config.Bandwidth.Down))
} }
hysteria2ConfigPortHopping, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigPortHopping)
if err != nil {
return "", err
}
if *hysteria2ConfigPortHopping.Value != "" {
// shadowrocket
urlConfig += fmt.Sprintf("&mport=%s", *hysteria2ConfigPortHopping.Value)
}
hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark) hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark)
if err != nil { if err != nil {
return "", err return "", err
@@ -278,6 +292,5 @@ func Hysteria2Url(accountId int64, hostname string) (string, error) {
if urlConfig != "" { if urlConfig != "" {
urlConfig = "/?" + strings.TrimPrefix(urlConfig, "&") urlConfig = "/?" + strings.TrimPrefix(urlConfig, "&")
} }
return fmt.Sprintf("hysteria2://%s@%s%s", *account.ConPass, hostname, *hysteria2Config.Listen) + urlConfig, nil return fmt.Sprintf("hysteria2://%s@%s:%d", *account.ConPass, hostname, port) + urlConfig, nil
} }
-1
View File
@@ -58,4 +58,3 @@ func GetToken(c *gin.Context) string {
} }
return strings.SplitN(tokenStr, " ", 2)[1] return strings.SplitN(tokenStr, " ", 2)[1]
} }
-1
View File
@@ -62,4 +62,3 @@ func MonitorHysteria2() (vo.Hysteria2MonitorVo, error) {
hysteria2MonitorVo.Running = running hysteria2MonitorVo.Running = running
return hysteria2MonitorVo, nil return hysteria2MonitorVo, nil
} }
-1
View File
@@ -67,4 +67,3 @@ func GetServerPortAndCert() (int64, string, string, error) {
return port, crtPath, keyPath, nil return port, crtPath, keyPath, nil
} }
-1
View File
@@ -30,4 +30,3 @@ func SplitArr[T any](arr []T, num int) [][]T {
return segments return segments
} }
+26
View File
@@ -2,7 +2,11 @@ package util
import ( import (
"crypto/sha256" "crypto/sha256"
"errors"
"fmt" "fmt"
"strings"
"golang.org/x/crypto/bcrypt"
) )
func SHA224String(password string) string { func SHA224String(password string) string {
@@ -16,3 +20,25 @@ func SHA224String(password string) string {
return str return str
} }
func HashPassword(password string) (string, error) {
if len(strings.TrimSpace(password)) < 6 {
return "", errors.New("password too short")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func IsBcryptHash(hash string) bool {
return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$")
}
func VerifyPassword(password string, storedHash string) (ok bool, legacy bool) {
if IsBcryptHash(storedHash) {
err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password))
return err == nil, false
}
return SHA224String(password) == storedHash, true
}
-1
View File
@@ -5,4 +5,3 @@ import "testing"
func TestSHA224String(t *testing.T) { func TestSHA224String(t *testing.T) {
println(SHA224String("sysadmin")) println(SHA224String("sysadmin"))
} }
-1
View File
@@ -38,4 +38,3 @@ func ExportFile(filePath string, data any, t int) error {
} }
return nil return nil
} }
-1
View File
@@ -86,4 +86,3 @@ func FindFile(dir, filename string) (string, error) {
} }
return result, nil return result, nil
} }
-58
View File
@@ -1,58 +0,0 @@
package util
import (
"context"
"fmt"
"github.com/google/go-github/v39/github"
)
var githubClient *github.Client
func init() {
githubClient = github.NewClient(nil)
}
func GetReleaseAssetURL(owner, repo, version, fileName string) (string, error) {
ctx := context.Background()
var release *github.RepositoryRelease
var err error
if version != "" {
release, _, err = githubClient.Repositories.GetReleaseByTag(ctx, owner, repo, version)
if err != nil {
return "", fmt.Errorf("failed to get release for version %s: %v", version, err)
}
} else {
releases, _, err := githubClient.Repositories.ListReleases(ctx, owner, repo, nil)
if err != nil {
return "", fmt.Errorf("failed to list releases: %v", err)
}
if len(releases) == 0 {
return "", fmt.Errorf("no releases found")
}
release = releases[0]
}
assets, _, err := githubClient.Repositories.ListReleaseAssets(ctx, owner, repo, release.GetID(), nil)
if err != nil {
return "", fmt.Errorf("failed to list release assets: %v", err)
}
for _, asset := range assets {
if asset.GetName() == fileName {
return asset.GetBrowserDownloadURL(), nil
}
}
return "", fmt.Errorf("file '%s' not found in release '%s'", fileName, release.GetTagName())
}
func ListRelease(owner, repo string) ([]*github.RepositoryRelease, error) {
ctx := context.Background()
releases, _, err := githubClient.Repositories.ListReleases(ctx, owner, repo, nil)
if err != nil {
return nil, fmt.Errorf("failed to list releases: %v", err)
}
return releases, nil
}
-57
View File
@@ -1,66 +1,9 @@
package util package util
import ( import (
"fmt"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"io"
"net/http"
"os"
"runtime"
) )
func GetHysteria2BinPath() string { func GetHysteria2BinPath() string {
return constant.Hysteria2BinPath return constant.Hysteria2BinPath
} }
func GetHysteria2BinName() string {
hysteria2FileName := fmt.Sprintf("hysteria-%s-%s", runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
hysteria2FileName += ".exe"
}
return hysteria2FileName
}
func DownloadHysteria2(version string) error {
hysteria2BinName := GetHysteria2BinName()
hysteria2BinPath := GetHysteria2BinPath()
// Download the latest version of Hysteria2
url, err := GetReleaseAssetURL("apernet", "hysteria", version, hysteria2BinName)
if err != nil {
return err
}
resp, err := http.Get(url)
defer resp.Body.Close()
if err != nil {
return fmt.Errorf("failed to download file: %v", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download file, status code: %d", resp.StatusCode)
}
if Exists(hysteria2BinPath) {
if err = os.Remove(hysteria2BinPath); err != nil {
return fmt.Errorf("failed to remove existing file: %v", err)
}
}
file, err := os.Create(hysteria2BinPath)
defer file.Close()
if err != nil {
return fmt.Errorf("failed to create file %s: %v", hysteria2BinPath, err)
}
_, err = io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("failed to write to file: %v", err)
}
if err = os.Chmod(hysteria2BinPath, 0755); err != nil {
return fmt.Errorf("failed to change file permissions: %v", err)
}
return nil
}
-1
View File
@@ -98,4 +98,3 @@ func VerifyPort(port string) error {
} }
return nil return nil
} }
-1
View File
@@ -21,4 +21,3 @@ func SplitMap[T any](inputMap map[string]T, chunkSize int) []map[string]T {
return segments return segments
} }
-1
View File
@@ -17,4 +17,3 @@ func RandomString(length int) (string, error) {
return string(bytes), nil return string(bytes), nil
} }
-1
View File
@@ -34,4 +34,3 @@ func CompareVersion(version1, version2 string) int {
// The version number is exactly the same // The version number is exactly the same
return 0 return 0
} }
+9 -1
View File
@@ -59,11 +59,18 @@ Hysteria2 — основной транспортный компонент се
## TLS ## TLS
Требования: Требования:
- нормальный домен - production default: `acme`
- поддерживаемые режимы: `acme | file | self_signed_dev`
- `self_signed_dev` только для dev/lab
- корректный `server_name` / SNI на клиентах - корректный `server_name` / SNI на клиентах
- одна понятная TLS policy - одна понятная TLS policy
- без смешивания нескольких несовместимых схем по умолчанию - без смешивания нескольких несовместимых схем по умолчанию
Инварианты:
- `acme` -> только `acme` block в конфиге;
- `file` -> только `tls.cert`/`tls.key` block;
- `self_signed_dev` -> только dev сценарии.
## Auth policy ## Auth policy
Для baseline выбирается одна предсказуемая auth-модель. Для baseline выбирается одна предсказуемая auth-модель.
@@ -88,6 +95,7 @@ Hysteria2 — основной транспортный компонент се
- `/etc/hysteria/config.yaml` - `/etc/hysteria/config.yaml`
- `/var/lib/hysteria/` - `/var/lib/hysteria/`
- `/etc/hy2xs/hy2xs.env`
- `/etc/hysteria/post-install.env` - `/etc/hysteria/post-install.env`
## Серверные инварианты ## Серверные инварианты
+3
View File
@@ -57,6 +57,7 @@ Bundled H UI должна:
- поставляться внутри итогового пакета - поставляться внутри итогового пакета
- иметь свой install dir - иметь свой install dir
- иметь свой data dir - иметь свой data dir
- иметь rootless systemd unit (`hy2xs-admin`)
- запускаться отдельным systemd unit - запускаться отдельным systemd unit
- не требовать target-side build - не требовать target-side build
@@ -76,6 +77,7 @@ Bundled H UI должна:
- Hysteria2 устанавливается install-оркестратором с official upstream - Hysteria2 устанавливается install-оркестратором с official upstream
- Hysteria2 запускается отдельным `hysteria-server.service` - Hysteria2 запускается отдельным `hysteria-server.service`
- HY2XS admin работает как operator UI и HTTP auth/traffic layer - HY2XS admin работает как operator UI и HTTP auth/traffic layer
- HY2XS admin не запускается от root
- смена версии Hysteria2 через UI отключена в baseline - смена версии Hysteria2 через UI отключена в baseline
- список upstream releases не является частью operator UI baseline - список upstream releases не является частью operator UI baseline
@@ -85,6 +87,7 @@ Bundled H UI должна:
- склеивать unit Hysteria и unit H UI в один сервис - склеивать unit Hysteria и unit H UI в один сервис
- раздувать оркестратор из-за особенностей UI - раздувать оркестратор из-за особенностей UI
- использовать HY2XS admin как updater бинаря Hysteria2 - использовать HY2XS admin как updater бинаря Hysteria2
- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API
## Что фиксировать в `post-install.env` ## Что фиксировать в `post-install.env`
+10 -1
View File
@@ -9,7 +9,7 @@
В baseline этого пакета docs входит только следующее: В baseline этого пакета docs входит только следующее:
- установка Hysteria2 - установка Hysteria2
- установка HY2XS admin - установка HY2XS admin
- базовая настройка systemd / firewall / `post-install.env` - базовая настройка systemd / firewall / `hy2xs.env` / `post-install.env`
- подготовка рабочего серверного окружения - подготовка рабочего серверного окружения
## Что не входит в baseline ## Что не входит в baseline
@@ -31,6 +31,15 @@
Но это не делает access layer частью install baseline. Но это не делает access layer частью install baseline.
## Публичный endpoint
В клиентской части используется только `public_host/public_port`.
Инварианты:
- `listen` и `public endpoint` разделены;
- в клиентских URL не используется `0.0.0.0`;
- проект остаётся IPv4-only.
## Почему это важно ## Почему это важно
Если смешать install baseline и delivery layer, документация начинает неверно обещать лишнее: Если смешать install baseline и delivery layer, документация начинает неверно обещать лишнее:
+20 -4
View File
@@ -21,10 +21,19 @@
Базовые требования: Базовые требования:
- отдельный unit `hy2xs-admin.service` - отдельный unit `hy2xs-admin.service`
- запуск от `User=hy2xs-admin`, не от root
- отдельный install dir - отдельный install dir
- отдельный data dir - отдельный data dir
- отдельный жизненный цикл от Hysteria - отдельный жизненный цикл от Hysteria
Рекомендуемый hardening:
- `NoNewPrivileges=true`
- `PrivateTmp=true`
- `ProtectHome=true`
- `ProtectSystem=strict`
- `ReadWritePaths=/var/lib/hy2xs-admin /var/log/hy2xs`
- `RestrictAddressFamilies=AF_INET AF_UNIX`
Важно: Важно:
- HY2XS admin не должен запускаться как часть unit Hysteria - HY2XS admin не должен запускаться как часть unit Hysteria
- unit-файлы не должны быть склеены - unit-файлы не должны быть склеены
@@ -36,14 +45,21 @@
- TCP-порт SSH - TCP-порт SSH
- established/related traffic - established/related traffic
IPv4-only policy:
- использовать `table ip`, а не `table inet`;
- IPv6 правила не добавлять;
- UI-порт разрешать только с локального bind-host.
После staged-проверки можно включать default policy `drop`. После staged-проверки можно включать default policy `drop`.
## Порядок применения ## Порядок применения
1. Добавить allow-правила. 1. Подготовить candidate-файл (`/etc/nftables.d/hy2xs.nft`).
2. Проверить, что SSH-сессия не теряется. 2. Проверить `nft -c -f`.
3. Проверить listen Hysteria-порта. 3. Создать rollback timer.
4. Только потом затягивать policy. 4. Применить candidate и проверить SSH/Hysteria/UI.
5. При успехе отменить rollback timer.
6. При провале — rollback.
## Что не делаем ## Что не делаем
+16 -14
View File
@@ -18,14 +18,15 @@
## Главная роль оркестратора ## Главная роль оркестратора
Оркестратор работает **только на target machine** и умеет только: Оркестратор работает **только на target machine** и умеет:
- выполнить первичную установку - выполнить первичную установку (`install`)
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
- разложить bundled UI - разложить bundled UI
- скачать Hysteria2 из official upstream - скачать Hysteria2 из official upstream
- создать базовые конфиги - создать/обновить конфиги
- создать unit-файлы - создать unit-файлы
- применить baseline firewall - применить staged firewall
- создать `post-install.env` - создать `post-install.env` и runtime env-файл
## Оркестратор не умеет ## Оркестратор не умеет
@@ -112,15 +113,16 @@
## CLI baseline ## CLI baseline
Допустимые флаги: Команды:
- `--non-interactive` - `install --package-dir <path> [--config /etc/hy2xs/hy2xs.env]`
- `--domain` - `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --dry-run`
- `--port` - `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --apply`
- `--ssh-port`
- `--skip-firewall` Инварианты:
- `--skip-start` - только IPv4 bind/listen;
- `--ui-port` - TLS modes: `acme | file | self_signed_dev`;
- `--ui-bind-host` - `trafficStats.secret` отдельный от `JWT_SECRET`;
- при `reconfigure --apply`: backup -> staged apply -> smoke -> rollback on fail.
## Что не реализовывать ## Что не реализовывать
+21 -10
View File
@@ -1,12 +1,20 @@
# Post-install env # Runtime env и post-install snapshot
## Цель документа ## Цель документа
Зафиксировать `post-install.env` как компактный deploy reference file после первичной установки. Зафиксировать двухслойную модель:
- editable runtime env: `/etc/hy2xs/hy2xs.env`
- generated deploy snapshot: `/etc/hysteria/post-install.env`
## Зачем нужен файл ## Зачем нужен файл
После первичной установки оператору нужна одна точка, где видно: После первичной установки оператору нужны:
1) runtime-файл, который оркестратор читает и валидирует;
2) snapshot-файл фактического deploy-состояния.
В snapshot видно:
- какой пакет был установлен - какой пакет был установлен
- какой build артефакт использован - какой build артефакт использован
- какой стек оркестратора применён - какой стек оркестратора применён
@@ -24,12 +32,15 @@
- не заменяет runtime-конфиги - не заменяет runtime-конфиги
- не превращает target в builder - не превращает target в builder
## Рекомендуемый путь ## Рекомендуемые пути
```bash ```bash
/etc/hy2xs/hy2xs.env
/etc/hysteria/post-install.env /etc/hysteria/post-install.env
``` ```
Оба файла должны иметь права `0600`.
## Минимальный набор переменных ## Минимальный набор переменных
### Deploy / package ### Deploy / package
@@ -73,18 +84,18 @@
- `HUI_INSTALL_DIR` - `HUI_INSTALL_DIR`
- `HUI_DATA_DIR` - `HUI_DATA_DIR`
## Как работать с файлом ## Как работать с файлами
Правильная модель: Правильная модель:
1. оркестратор создаёт файл при первичной установке 1. оркестратор создаёт `hy2xs.env` и `post-install.env` при установке;
2. оператор использует файл как reference/source-of-truth 2. оператор редактирует только `hy2xs.env`;
3. при необходимости оператор вручную переносит изменения в реальные рабочие конфиги 3. оператор запускает `reconfigure --dry-run`, затем `reconfigure --apply`;
4. затем оператор применяет изменения документированным способом 4. оркестратор обновляет runtime и перезаписывает snapshot.
## Что нельзя делать ## Что нельзя делать
- сваливать туда временный мусор - сваливать туда временный мусор
- считать, что edit env автоматически меняет runtime - считать, что edit env автоматически меняет runtime без `reconfigure --apply`
- использовать файл как замену настоящей конфигурации сервисов - использовать файл как замену настоящей конфигурации сервисов
## Пример ## Пример
+13 -4
View File
@@ -25,21 +25,26 @@
3. bundled HY2XS admin раскладывается локально из пакета 3. bundled HY2XS admin раскладывается локально из пакета
4. создаются нужные каталоги 4. создаются нужные каталоги
5. создаются systemd unit-файлы 5. создаются systemd unit-файлы
6. создаётся `post-install.env` 6. создаются `hy2xs.env` и `post-install.env` с правами `0600`
7. baseline firewall применяется корректно 7. baseline firewall применяется корректно через staged mode
8. SSH остаётся доступным 8. SSH остаётся доступным
9. `reconfigure --dry-run` выводит план изменений
10. `reconfigure --apply` применяет изменения и проходит smoke
## C. Runtime tests ## C. Runtime tests
1. `hysteria-server` active 1. `hysteria-server` active
2. `hy2xs-admin` active 2. `hy2xs-admin` active
3. UDP-порт слушается 3. Hysteria слушает только IPv4 (`0.0.0.0:<udp_port>`)
4. HY2XS admin открывается по ожидаемому admin path 4. HY2XS admin слушает ожидаемый `HY2XS_UI_BIND_HOST:<ui_port>`
5. тестовый совместимый клиент подключается 5. тестовый совместимый клиент подключается
6. идёт реальный трафик 6. идёт реальный трафик
7. лимит 50/50 Mbps соблюдается при согласованной клиентской конфигурации 7. лимит 50/50 Mbps соблюдается при согласованной клиентской конфигурации
8. reboot не ломает baseline 8. reboot не ломает baseline
9. Hysteria2 управляется systemd unit, а не внутренним updater'ом admin panel 9. Hysteria2 управляется systemd unit, а не внутренним updater'ом admin panel
10. нет IPv6 listen (`[::]`) для Hysteria/HY2XS admin
11. `trafficStats.secret` не равен `JWT_SECRET`
12. bootstrap admin secret существует и имеет `0600`
## D. Negative tests ## D. Negative tests
@@ -51,6 +56,8 @@
6. Hysteria upstream недоступен 6. Hysteria upstream недоступен
7. firewall применился частично 7. firewall применился частично
8. install flow прерван посередине 8. install flow прерван посередине
9. попытка использовать `HY2XS_IPV6_ENABLED=true`
10. `HY2XS_PUBLIC_HOST=0.0.0.0`
## Acceptance criteria ## Acceptance criteria
@@ -64,3 +71,5 @@
6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт 6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт
7. оркестратор не требует update / rollback / uninstall логики 7. оркестратор не требует update / rollback / uninstall логики
8. Telegram/access layer не требуется для прохождения install acceptance 8. Telegram/access layer не требуется для прохождения install acceptance
9. отсутствует production path для port hopping
10. UI не запускается от root
+15 -3
View File
@@ -9,16 +9,27 @@ PACKAGE_VERSION=0.1.0
ORCH_SOURCE_STACK=bun-typescript ORCH_SOURCE_STACK=bun-typescript
ORCH_BUILD_MODE=bun-compile ORCH_BUILD_MODE=bun-compile
ORCH_BUILD_ID=orch-build-20260413-001 ORCH_BUILD_ID=build-20260413-001
ORCH_ENTRYPOINT=/usr/local/lib/hy2xs/hy2xs-orchestrator ORCH_ENTRYPOINT=/usr/local/lib/hy2xs/hy2xs-orchestrator
DEPLOY_DOMAIN=example.com DEPLOY_DOMAIN=example.com
PUBLIC_HOST=example.com
PUBLIC_PORT=443
SSH_PORT=22 SSH_PORT=22
HY2XS_FIREWALL_ENABLED=true
HY2XS_FIREWALL_STAGED_APPLY=true
HY2_SOURCE=official-upstream HY2_SOURCE=official-upstream
HY2_VERSION=v2.8.1 HY2_VERSION=v2.8.1
HY2_TLS_MODE=acme
HY2_ACME_EMAIL=admin@example.com
HY2_TLS_CERT_PATH=
HY2_TLS_KEY_PATH=
HY2_LISTEN_HOST=0.0.0.0 HY2_LISTEN_HOST=0.0.0.0
HY2_PORT=443 HY2_PORT=443
HY2_AUTH_MODE=http
HY2_AUTH_URL=http://127.0.0.1:8081/hui/hysteria2/auth
HY2_TRAFFIC_STATS_LISTEN=127.0.0.1:25413
HY2_OBFS_TYPE=salamander HY2_OBFS_TYPE=salamander
HY2_OBFS_PASSWORD=CHANGE_ME HY2_OBFS_PASSWORD=CHANGE_ME
HY2_BANDWIDTH_UP_Mbps=50 HY2_BANDWIDTH_UP_Mbps=50
@@ -27,9 +38,10 @@ HY2_IGNORE_CLIENT_BANDWIDTH=false
HY2_CONFIG_PATH=/etc/hysteria/config.yaml HY2_CONFIG_PATH=/etc/hysteria/config.yaml
HUI_ENABLED=true HUI_ENABLED=true
HUI_FORK_REF=main HUI_FORK_REF=packaged
HUI_BUILD_ID=hy2xs-admin-build-20260413-001 HUI_BUILD_ID=build-20260413-001
HUI_BIND_HOST=127.0.0.1 HUI_BIND_HOST=127.0.0.1
HUI_PORT=8081 HUI_PORT=8081
HUI_INSTALL_DIR=/opt/hy2xs-admin HUI_INSTALL_DIR=/opt/hy2xs-admin
HUI_DATA_DIR=/var/lib/hy2xs-admin HUI_DATA_DIR=/var/lib/hy2xs-admin
HUI_LOG_DIR=/var/log/hy2xs-admin
+705
View File
@@ -0,0 +1,705 @@
Ты работаешь с проектом HY2XS_flamy / HY2XS.
Контекст проекта:
HY2XS — это production installer/orchestrator для установки Hysteria2 + встроенной HY2XS admin panel на Debian 12 VPS. Оркестратор написан на Bun + TypeScript, admin panel является fork/refactor h-ui и собирается заранее, на target-сервере сборки быть не должно. Hysteria2 должен оставаться upstream vanilla binary. Проект не должен превращаться в Telegram-бота, CRM или generic server manager. Это узкий installer/runtime manager для Hysteria2 + HY2XS admin.
Главная задача:
Провести production-hardening pass без костылей и без legacy. Исправить код, шаблоны, systemd, firewall, env/config layer, документацию и acceptance checks так, чтобы проект соответствовал официальным подходам Hysteria2/systemd и собственным docs проекта.
ВАЖНО:
- Не делай косметические правки вместо архитектурных.
- Не добавляй временные обходы.
- Не оставляй legacy h-ui функциональность, если она не входит в scope HY2XS.
- Не меняй архитектуру на “собирать на target-сервере”.
- Не форкай Hysteria2.
- Не делай IPv6 support. В этом проекте серверы только IPv4. IPv6 должен быть явно out of scope.
- Все bind/listen должны быть IPv4-only.
- Все секреты должны быть chmod 600/root-only или доступны только нужному сервисному пользователю.
- После правок обнови документацию, примеры и acceptance criteria.
Перед правками:
1. Изучи текущую структуру проекта:
- README.md
- docs/*.md
- hy2xs_implementation_plan-no_git/*.txt
- orchestrator/src/**
- package/templates/**
- package/systemd/**
- package/examples/**
- apps/**
- apps/frontend/**
- tools/build/**
2. Сверь спорные места с официальными docs:
- Hysteria2 server config
- Hysteria2 ACME/TLS
- Hysteria2 HTTP auth
- Hysteria2 trafficStats API
- Hysteria2 install script behavior
- systemd hardening options
- nftables safe apply practices
- Bun standalone compile behavior
3. Найди рассинхроны между docs и реализацией.
4. Исправь реализацию под production, а не просто подгони docs под текущий код.
Ключевые проблемы, которые нужно исправить:
1. Реализовать нормальный env/config layer
Сейчас /etc/hysteria/post-install.env существует как reference-файл, но фактически не является полноценным слоем конфигурации.
Нужно:
- Ввести единый runtime/deploy env-файл, который оркестратор умеет читать, валидировать и применять.
- Рекомендуемый путь:
- /etc/hy2xs/hy2xs.env как основной editable config layer
- /etc/hysteria/post-install.env как generated deployment snapshot/reference
Либо можно оставить один /etc/hysteria/post-install.env, но только если он реально становится читаемым оркестратором config layer. Предпочтительно разделить editable config и generated snapshot.
- Файл должен иметь права 0600.
- Файл должен содержать основные production-параметры:
- HY2XS_DOMAIN
- HY2XS_PUBLIC_HOST
- HY2XS_PUBLIC_PORT
- HY2XS_IPV6_ENABLED=false
- HY2XS_SSH_PORT
- HY2XS_FIREWALL_ENABLED
- HY2XS_FIREWALL_STAGED_APPLY
- HY2XS_UI_BIND_HOST=127.0.0.1
- HY2XS_UI_PORT
- HY2XS_ADMIN_USER
- HY2XS_ADMIN_INITIAL_PASSWORD или путь к bootstrap secret file
- HY2XS_FORCE_PASSWORD_CHANGE=true
- HY2XS_TLS_MODE=acme|file|self_signed_dev
- HY2XS_ACME_EMAIL
- HY2XS_TLS_CERT_PATH
- HY2XS_TLS_KEY_PATH
- HY2XS_HYSTERIA_BIND_HOST=0.0.0.0
- HY2XS_HYSTERIA_PORT=443
- HY2XS_HYSTERIA_AUTH_MODE=http
- HY2XS_HYSTERIA_AUTH_URL=http://127.0.0.1:<ui_port>/hui/hysteria2/auth
- HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1
- HY2XS_HYSTERIA_TRAFFIC_STATS_PORT
- HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET
- HY2XS_HYSTERIA_OBFS_TYPE=salamander
- HY2XS_HYSTERIA_OBFS_PASSWORD
- HY2XS_HYSTERIA_BANDWIDTH_UP
- HY2XS_HYSTERIA_BANDWIDTH_DOWN
- HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=false
- HY2XS_HYSTERIA_CONFIG_PATH=/etc/hysteria/config.yaml
- HY2XS_HYSTERIA_VERSION or latest policy
- HY2XS_INSTALL_DIR=/opt/hy2xs-admin
- HY2XS_DATA_DIR=/var/lib/hy2xs-admin
- HY2XS_LOG_DIR=/var/log/hy2xs
- Добавить в orchestrator:
- загрузку env-файла;
- строгую валидацию;
- нормализацию типов;
- запрет несовместимых комбинаций;
- dry-run вывод;
- явную команду reconfigure.
- Изменение env-файла не должно автоматически менять runtime.
- Применение только через явную команду:
- hy2xs-orchestrator install --config /path/to/hy2xs.env
- hy2xs-orchestrator reconfigure --config /etc/hy2xs/hy2xs.env --dry-run
- hy2xs-orchestrator reconfigure --config /etc/hy2xs/hy2xs.env --apply
- Если reconfigure меняет port/listen/firewall/TLS, он должен:
- валидировать новый config;
- сделать backup текущих config/unit/firewall;
- применить staged;
- выполнить smoke-check;
- откатить изменения при провале.
Файлы для правок:
- orchestrator/src/cli.ts
- orchestrator/src/types/context.ts
- orchestrator/src/commands/install.ts
- добавить orchestrator/src/commands/reconfigure.ts
- добавить orchestrator/src/config/env.ts или похожий модуль
- orchestrator/src/steps/env.ts
- package/templates/env/*
- package/examples/*
- docs/09-post-install-env.md
- docs/08-orchestrator-spec.md
- docs/11-testing-and-acceptance.md
2. Зафиксировать IPv4-only модель
Требование:
Проект работает только с IPv4. IPv6 не нужен и не должен случайно включаться.
Нужно:
- В Hysteria config не использовать listen вида :443.
- Использовать только:
listen: 0.0.0.0:<port>
- HY2XS admin должен слушать только IPv4 bind host:
127.0.0.1:<ui_port> по умолчанию.
- Не использовать [::], ::, dual-stack listen.
- В docs явно указать:
- IPv6 unsupported/out of scope;
- все публичные входящие соединения идут через IPv4;
- домен должен иметь A-record;
- AAAA-record не используется и не требуется;
- если у домена есть AAAA, оператор должен понимать, что HY2XS его не обслуживает.
- В preflight добавить проверку:
- домен резолвится в IPv4 A-record;
- если есть AAAA, вывести warning, но не включать IPv6;
- public_host/client URL строится только по IPv4-домену/IPv4 endpoint.
- В firewall использовать IPv4-only nftables rules. Не использовать inet table как способ “случайно покрыть IPv6”, если проект IPv4-only.
- Если на сервере IPv6 включен, HY2XS всё равно не должен слушать IPv6.
Файлы:
- package/templates/hysteria/config.yaml.tpl
- package/templates/nftables/hy2xs.nft.tpl
- orchestrator/src/steps/preflight.ts
- orchestrator/src/steps/config.ts
- orchestrator/src/steps/firewall.ts
- docs/03-server-hysteria2.md
- docs/05-client-and-access-scope.md
- docs/07-systemd-and-firewall.md
- docs/12-operations-and-troubleshooting.md
3. Исправить Hysteria2 listen/public endpoint split
Сейчас есть риск, что client URL строится из listen, что неверно.
Нужно разделить:
- bind_host: 0.0.0.0
- bind_port: 443
- public_host: domain or IPv4
- public_port: 443
Hysteria server config использует bind_host/bind_port.
Client URL/subscription использует public_host/public_port.
Нельзя строить client URL из поля listen.
Нельзя использовать 0.0.0.0 в client URL.
Проверь и исправь:
- apps/service/hysteria2_api.go
- apps/model/bo/hysteria2.go
- apps/model/bo/hysteria2_api.go
- apps/model/dto/hysteria2.go
- apps/model/vo/hysteria2.go
- все места, где public URL строится через Listen.
- package/templates/hysteria/config.yaml.tpl
- docs.
Acceptance:
- При listen: 0.0.0.0:443 клиентский URL должен быть hysteria2://password@example.com:443/...
- В URL не должно быть 0.0.0.0.
- При public_port=443 порт можно явно оставить или нормализовать по принятому формату, но поведение должно быть стабильным и протестированным.
4. Исправить TLS/ACME flow
Сейчас self-signed сертификат фактически используется как default. Это не production-ready.
Нужно:
- Добавить TLS modes:
- acme — production default
- file — production для собственного cert/key
- self_signed_dev — только dev/lab, запрещён для non-interactive production без явного флага
- В production default использовать ACME, если задан domain и acme_email.
- Для acme mode Hysteria config должен использовать acme block, а не tls cert/key.
- Для file mode использовать tls.cert/tls.key.
- Для self_signed_dev:
- явно генерировать self-signed cert;
- явно маркировать install как dev/lab;
- client config должен включать insecure только для этого режима.
- Не смешивать acme и tls file в одном config.
- Добавить в env:
- HY2XS_TLS_MODE
- HY2XS_ACME_EMAIL
- HY2XS_TLS_CERT_PATH
- HY2XS_TLS_KEY_PATH
- Проверить, что generated config соответствует официальной схеме Hysteria2.
Файлы:
- orchestrator/src/steps/config.ts
- package/templates/hysteria/config.yaml.tpl
- package/templates/env/*
- docs/03-server-hysteria2.md
- docs/05-client-and-access-scope.md
- docs/11-testing-and-acceptance.md
Acceptance:
- acme mode генерирует только acme block.
- file mode генерирует только tls cert/key block.
- self_signed_dev генерирует tls cert/key + client insecure.
- production install без domain/acme_email должен завершиться ошибкой или потребовать явный self_signed_dev.
5. Исправить TrafficStats API secret
Сейчас Hysteria config получает HYSTERIA_API_SECRET, но HY2XS admin использует JWT_SECRET для trafficStats API. Это неверно.
Нужно:
- Ввести отдельную настройку:
HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET
- Один и тот же secret должен использоваться:
- в Hysteria config:
trafficStats.secret
- в HY2XS admin при вызовах:
/traffic
/online
kick user / disconnect if exists
- Не использовать JWT_SECRET для Hysteria API.
- JWT_SECRET должен отвечать только за авторизацию HY2XS admin.
- Добавить smoke-check:
- запрос к trafficStats API с правильным secret проходит;
- запрос с неправильным secret не проходит.
- Обновить docs.
Файлы:
- apps/service/hysteria2_api.go
- apps/proxy/hysteria2_api.go
- apps/model/constant/config.go
- apps/service/config.go
- orchestrator/src/steps/config.ts
- package/templates/hysteria/config.yaml.tpl
- package/templates/env/*
- docs/03-server-hysteria2.md
- docs/04-admin-panel-h-ui-fork.md
- docs/11-testing-and-acceptance.md
6. Убрать default admin sysadmin/sysadmin
Сейчас seed создаёт admin sysadmin/sysadmin. Это production blocker.
Нужно:
- Убрать дефолтный predictable admin password.
- Во время install генерировать bootstrap admin password.
- Записывать его в root-only файл:
/etc/hy2xs/bootstrap-admin.secret
chmod 600
- Или выводить один раз в конце установки + сохранять в защищённый файл.
- Admin username должен задаваться через env:
HY2XS_ADMIN_USER
- При первом логине требовать смену пароля:
HY2XS_FORCE_PASSWORD_CHANGE=true
- Не использовать con_pass = admin password.
- con_pass для Hysteria user должен быть отдельным случайным secret.
- Перейти с SHA224 на нормальный password hashing:
- bcrypt или argon2id.
- Сделать backward-compatible migration:
- старые SHA224 пароли распознавать;
- после успешного login перехешировать в новый формат;
- новые пароли сохранять только в новом формате.
- Обновить docs.
Файлы:
- apps/dao/sqlite.go
- apps/util/encrypt.go
- apps/service/account.go
- apps/model/entity/account.go
- apps/model/dto/account.go
- apps/controller/account.go
- orchestrator/src/commands/install.ts
- orchestrator/src/steps/env.ts
- package/templates/env/*
- docs/04-admin-panel-h-ui-fork.md
- docs/09-post-install-env.md
- docs/11-testing-and-acceptance.md
Acceptance:
- После fresh install нет sysadmin/sysadmin.
- Login невозможен старым дефолтным паролем.
- Bootstrap password уникален для каждой установки.
- Password hash не является быстрым SHA digest.
- force password change работает.
7. Исправить UI bind host
Сейчас CLI имеет --ui-bind-host, systemd передаёт HY2XS_UI_BIND_HOST, но Go server фактически слушает :port на всех интерфейсах.
Нужно:
- Go server должен читать HY2XS_UI_BIND_HOST.
- Default: 127.0.0.1.
- Addr должен быть:
127.0.0.1:<port>
или другой явно заданный IPv4.
- Запретить пустой host как bind all.
- Запретить :: и IPv6 bind.
- Если оператор явно задаёт 0.0.0.0, требовать warning/explicit flag, либо запретить в production.
- systemd unit должен передавать HY2XS_UI_BIND_HOST.
- smoke-check должен проверять, что UI не слушает 0.0.0.0, если bind host 127.0.0.1.
Файлы:
- apps/cmd/server.go
- apps/service/server.go
- package/systemd/hy2xs-admin.service
- orchestrator/src/steps/systemd.ts
- orchestrator/src/steps/smoke.ts
- docs/04-admin-panel-h-ui-fork.md
- docs/07-systemd-and-firewall.md
Acceptance:
- ss -ltnp показывает 127.0.0.1:<ui_port>, а не 0.0.0.0:<ui_port>.
- UI недоступен с внешнего интерфейса без reverse proxy/tunnel.
- --ui-bind-host реально влияет на bind.
8. Запускать HY2XS admin не от root
Сейчас systemd unit не задаёт User/Group, значит admin работает от root.
Нужно:
- Создать системного пользователя:
hy2xs-admin
- Запускать HY2XS admin от User=hy2xs-admin Group=hy2xs-admin.
- Настроить директории:
- /opt/hy2xs-admin read-only для binary/assets
- /var/lib/hy2xs-admin writable data
- /var/log/hy2xs writable logs
- В systemd добавить hardening:
- NoNewPrivileges=true
- PrivateTmp=true
- ProtectHome=true
- ProtectSystem=strict
- ReadWritePaths=/var/lib/hy2xs-admin /var/log/hy2xs
- RestrictAddressFamilies=AF_INET AF_UNIX
- CapabilityBoundingSet= empty where possible
- AmbientCapabilities= empty where possible
- Если UI должен перезапускать Hysteria или читать privileged files, не запускать весь UI от root.
Вместо этого:
- либо убрать такие операции из UI;
- либо сделать отдельный узкий root helper;
- либо использовать строго ограниченный sudoers/polkit rule.
- Не оставлять широкие root-права.
Файлы:
- package/systemd/hy2xs-admin.service
- orchestrator/src/steps/filesystem.ts
- orchestrator/src/steps/systemd.ts
- apps/proxy/process.go
- apps/service/hysteria2.go
- apps/service/config.go
- docs/04-admin-panel-h-ui-fork.md
- docs/07-systemd-and-firewall.md
Acceptance:
- systemctl show hy2xs-admin показывает User=hy2xs-admin.
- Процесс не root.
- UI продолжает работать.
- Нет записи в /etc/hysteria/config.yaml от имени неподходящего процесса без контролируемого helper/reconfigure.
9. Исправить права /etc/hysteria/config.yaml
Сейчас orchestrator пишет config chmod 0600, но UI может перезаписать его chmod 0644.
Нужно:
- Всегда писать /etc/hysteria/config.yaml с правами 0600.
- owner должен быть корректным для Hysteria service.
- Если UI больше не должен напрямую писать config, убрать такую возможность или перенести в orchestrator reconfigure.
- Писать config atomic:
- temporary file
- fsync if needed
- chmod/chown
- rename
- validate
- restart/reload
- Не допускать world-readable secrets.
Файлы:
- orchestrator/src/steps/config.ts
- apps/service/hysteria2.go
- apps/service/config.go
- docs/03-server-hysteria2.md
- docs/07-systemd-and-firewall.md
Acceptance:
- stat -c "%a %U %G" /etc/hysteria/config.yaml показывает 600 и ожидаемого owner/group.
- В config есть secrets, но файл не world-readable.
- UI не ослабляет права после сохранения.
10. Переделать firewall apply на безопасный staged mode
Сейчас firewall может резко применить policy drop и отрезать SSH.
Нужно:
- Не перезаписывать слепо весь /etc/nftables.conf без staged rollback.
- Использовать include-файл, например:
/etc/nftables.d/hy2xs.nft
- Перед apply выполнять:
nft -c -f <candidate>
- Сделать staged apply:
- backup current rules/config
- install rollback timer через systemd-run или эквивалент
- apply candidate
- проверить SSH port rule
- проверить Hysteria UDP port
- проверить UI local bind
- если checks passed — cancel rollback
- если failed — rollback
- IPv4-only:
- table ip hy2xs
- не использовать IPv6 rules как supported path.
- Allow:
- lo
- established/related
- SSH TCP port from IPv4
- Hysteria UDP port from IPv4
- optionally ICMP
- UI TCP только с 127.0.0.1
- Default drop только после staged safety.
- Если --skip-firewall, не открывать UI публично.
Файлы:
- orchestrator/src/steps/firewall.ts
- package/templates/nftables/hy2xs.nft.tpl
- docs/07-systemd-and-firewall.md
- docs/11-testing-and-acceptance.md
Acceptance:
- nft -c проходит.
- Неправильный SSH port не применяет irreversible lockout.
- UI не доступен с внешнего интерфейса.
- Hysteria UDP port открыт.
- IPv6 не считается поддерживаемым.
11. Удалить port hopping legacy
Docs говорят no port hopping, но code всё ещё инициализирует:
- service.InitTableAndChain()
- service.InitPortHopping()
Нужно:
- Убрать port hopping из production path.
- Удалить или отключить:
- apps/service/forward.go
- связанные nft/iptables вызовы
- связанные routes/UI элементы
- связанные config fields
- Если удалить полностью нельзя за один pass, закрыть build tag’ом или runtime flag default false, но production binary не должен инициализировать port hopping.
- Не создавать nft table/chain при старте HY2XS admin.
- Обновить docs.
Файлы:
- apps/cmd/server.go
- apps/service/forward.go
- apps/router/*
- apps/frontend/src/views/*
- docs/04-admin-panel-h-ui-fork.md
- docs/10-access-layer-out-of-scope.md
Acceptance:
- grep по InitPortHopping не показывает production вызов.
- При старте UI не создаёт nft/iptables rules.
- В UI нет port hopping управления.
12. Убрать legacy updater/GitHub logic из UI
HY2XS admin не должен быть updater’ом h-ui/original repo.
Нужно:
- Найти и удалить legacy code, связанный с:
- original h-ui repo
- GitHub releases
- self-update UI
- update checker
- install/update Hysteria из UI, если это зона orchestrator
- В Go dependency audit:
- github.com/google/go-github/v39 сейчас используется только apps/util/github.go.
- Если self-update/GitHub release logic удаляется, удалить util/github.go и go-github из go.mod/go.sum.
- Не оставлять неиспользуемые функции “на потом”.
Файлы:
- apps/util/github.go
- apps/go.mod
- apps/go.sum
- apps/controller/config.go
- apps/service/config.go
- frontend config/settings views
- docs/04-admin-panel-h-ui-fork.md
- docs/10-access-layer-out-of-scope.md
Acceptance:
- go mod tidy удаляет google/go-github, если больше не нужен.
- UI не показывает updater legacy.
- Docs говорят, что update/upgrade — зона orchestrator/package, не admin panel.
13. Проверить и почистить frontend dependencies
В apps/frontend/package.json есть вероятные рудименты.
Проверь фактическое использование и удали неиспользуемое:
- echarts — в текущем коде не найдено использование.
- screenfull — в текущем коде не найдено использование.
- path-to-regexp — в текущем коде не найдено использование.
- fast-glob — в vite.config.ts не используется.
- @types/path-browserify — нужен только если path-browserify остаётся.
- vite-plugin-dynamic-base — используется в vite.config.ts, но проверить, нужен ли HY2XS dynamic base. Если UI всегда от root path, убрать.
- unocss — используется в vite config, но проверить, реально ли есть UnoCSS classes. Если нет — убрать.
- unplugin-icons / @iconify-json/ep — оставить только если реально нужны auto icons.
- vuedraggable — используется в ImputMultiple, оставить если компонент нужен.
- path-browserify — используется в Sidebar/TagsView, оставить или заменить на простую path join utility без browser polyfill.
- @vueuse/core — используется, оставить или заменить точечно, если хочется уменьшить bundle.
- nprogress — используется, оставить.
- copy-to-clipboard — используется, оставить или заменить navigator.clipboard, если поддержка позволяет.
После удаления:
- обновить pnpm-lock.yaml;
- убедиться, что frontend build проходит;
- удалить сгенерированные auto-import/component d.ts, если они не должны быть в repo, либо обновить их корректно;
- не оставлять импортов на удалённые пакеты.
Файлы:
- apps/frontend/package.json
- apps/frontend/pnpm-lock.yaml
- apps/frontend/vite.config.ts
- apps/frontend/src/**
- docs/04-admin-panel-h-ui-fork.md
Acceptance:
- pnpm install --frozen-lockfile проходит.
- pnpm build:prod проходит.
- grep не находит удалённые пакеты в src/vite config.
- UI работает без legacy dependencies.
14. Исправить Hysteria install flow
Сейчас installer использует official script через curl/sh и не pin’ит версию.
Нужно:
- Добавить возможность pin версии:
HY2XS_HYSTERIA_VERSION=vX.Y.Z или latest.
- Если latest — явно записать фактически установленную версию в post-install snapshot.
- Использовать official installer корректно.
- Если используется curl | bash/script, сделать безопаснее:
- скачать script в temp file;
- проверить download success;
- по возможности checksum/signature/version;
- запускать bash, если official docs требуют bash.
- После установки проверить:
- binary exists;
- hysteria version;
- expected service conflict отсутствует или осознанно заменён.
- Если official installer создаёт свой service, HY2XS должен явно документировать и контролировать replacement.
Файлы:
- orchestrator/src/steps/hysteria.ts
- orchestrator/src/steps/smoke.ts
- docs/02-build-layer-and-package.md
- docs/03-server-hysteria2.md
- docs/08-orchestrator-spec.md
15. Улучшить smoke/acceptance checks
Текущий smoke слишком слабый.
Добавить проверки:
- systemd units active:
- hysteria-server
- hy2xs-admin
- Hysteria UDP port слушает IPv4.
- UI слушает только expected IPv4 bind host.
- UI не слушает 0.0.0.0 при bind 127.0.0.1.
- Auth endpoint отвечает корректно:
- /hui/hysteria2/auth для тестового/существующего пользователя.
- TrafficStats API:
- correct secret works;
- wrong secret denied.
- Hysteria config syntactically valid.
- TLS mode соответствует env:
- acme mode содержит acme и не содержит tls cert/key.
- file mode содержит tls cert/key и не содержит acme.
- self_signed_dev clearly marked.
- Firewall rules installed and nft -c passes.
- File permissions:
- /etc/hy2xs/hy2xs.env 0600
- /etc/hysteria/post-install.env 0600
- /etc/hysteria/config.yaml 0600
- bootstrap-admin.secret 0600
- No default sysadmin/sysadmin.
- No IPv6 listen:
- ss output must not show [::] for HY2XS/Hysteria.
- No port hopping init.
- Reboot persistence documented or testable.
Файлы:
- orchestrator/src/steps/smoke.ts
- docs/11-testing-and-acceptance.md
- package/install.sh
16. Обновить docs и убрать рассинхрон
Обновить:
- README.md
- docs/01-architecture-baseline.md
- docs/02-build-layer-and-package.md
- docs/03-server-hysteria2.md
- docs/04-admin-panel-h-ui-fork.md
- docs/05-client-and-access-scope.md
- docs/06-speed-limits-and-congestion.md
- docs/07-systemd-and-firewall.md
- docs/08-orchestrator-spec.md
- docs/09-post-install-env.md
- docs/10-access-layer-out-of-scope.md
- docs/11-testing-and-acceptance.md
- docs/12-operations-and-troubleshooting.md
- hy2xs_implementation_plan-no_git/*.txt если они остаются как актуальные документы.
Docs должны фиксировать:
- HY2XS = Bun/TypeScript orchestrator + bundled HY2XS admin + upstream Hysteria2.
- No Telegram bot.
- No port hopping.
- No target-side build.
- No IPv6 support.
- IPv4-only bind/listen.
- Production TLS default = ACME.
- self-signed only dev/lab.
- env/config layer and reconfigure flow.
- admin panel не updater и не generic server manager.
- admin panel не работает от root.
- firewall staged apply.
- secret/file permission policy.
- acceptance checks.
Также исправить README:
- Убедиться, что build command указывает реальный путь.
- Если фактический build script tools/build/build.sh, README не должен ссылаться на builder/build.sh.
- Все команды должны соответствовать текущей структуре.
17. Общие требования к качеству
После правок выполнить:
- gofmt по Go-коду.
- go mod tidy.
- frontend lockfile update.
- TypeScript check для orchestrator.
- Build orchestrator.
- Build frontend.
- Build Go admin.
- Smoke если возможно в среде.
- grep-проверки:
- нет sysadmin/sysadmin как production seed.
- нет InitPortHopping production вызова.
- нет listen: :{{HYSTERIA_PORT}}.
- нет UI bind ":8080" без host.
- нет JWT_SECRET как Hysteria trafficStats secret.
- нет chmod 0644 для /etc/hysteria/config.yaml.
- нет IPv6 listen [::] / "::" в runtime defaults.
- нет h-ui legacy updater/original repo references, если они вне scope.
Не делай:
- Не оставляй TODO вместо исправления P0.
- Не добавляй reverse proxy как обязательную часть.
- Не добавляй Docker requirement.
- Не добавляй Telegram/bot features.
- Не добавляй IPv6.
- Не оставляй root admin panel.
- Не делай “если не получилось, chmod 777”.
- Не ослабляй firewall ради прохождения smoke.
- Не используй JWT_SECRET для Hysteria API.
- Не используй 0.0.0.0 в client URL.
- Не делай self-signed production default.
Ожидаемый результат:
1. Полный diff с исправлением production blockers.
2. Обновлённые docs.
3. Обновлённые templates/examples.
4. Удалённые legacy dependencies/code.
5. Понятный reconfigure/env flow.
6. IPv4-only модель зафиксирована в коде и docs.
7. Расширенные smoke/acceptance checks.
8. Краткий отчёт:
- что исправлено;
- какие файлы изменены;
- какие legacy части удалены;
- какие команды проверки запускались;
- что осталось как tech debt, если что-то невозможно закрыть за один pass.
+66 -33
View File
@@ -1,8 +1,11 @@
import { install } from "./commands/install"; import { install } from "./commands/install";
import type { InstallOptions } from "./types/context"; import { reconfigure } from "./commands/reconfigure";
import type { InstallOptions, ReconfigureOptions } from "./types/context";
function usage(): never { function usage(): never {
console.error("Usage: hy2xs-orchestrator install --package-dir <path> [--domain <name>] [--port <udp>] [--ssh-port <tcp>] [--skip-firewall] [--skip-start] [--ui-port <tcp>] [--ui-bind-host <host>] [--non-interactive]"); console.error("Usage:");
console.error(" hy2xs-orchestrator install --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start] [--non-interactive]");
console.error(" hy2xs-orchestrator reconfigure --package-dir <path> [--config <path>] [--dry-run|--apply] [--skip-firewall] [--skip-start]");
process.exit(2); process.exit(2);
} }
@@ -18,14 +21,10 @@ function takeValue(args: string[], index: number, flag: string): string {
function parseInstallOptions(args: string[]): InstallOptions { function parseInstallOptions(args: string[]): InstallOptions {
const options: InstallOptions = { const options: InstallOptions = {
packageDir: "", packageDir: "",
configPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: false, nonInteractive: false,
domain: "",
port: 443,
sshPort: 22,
skipFirewall: false, skipFirewall: false,
skipStart: false, skipStart: false
uiPort: 8080,
uiBindHost: "127.0.0.1"
}; };
for (let i = 0; i < args.length; i += 1) { for (let i = 0; i < args.length; i += 1) {
@@ -38,30 +37,14 @@ function parseInstallOptions(args: string[]): InstallOptions {
case "--non-interactive": case "--non-interactive":
options.nonInteractive = true; options.nonInteractive = true;
break; break;
case "--domain":
options.domain = takeValue(args, i, arg);
i += 1;
break;
case "--port":
options.port = Number(takeValue(args, i, arg));
i += 1;
break;
case "--ssh-port":
options.sshPort = Number(takeValue(args, i, arg));
i += 1;
break;
case "--skip-firewall": case "--skip-firewall":
options.skipFirewall = true; options.skipFirewall = true;
break; break;
case "--skip-start": case "--skip-start":
options.skipStart = true; options.skipStart = true;
break; break;
case "--ui-port": case "--config":
options.uiPort = Number(takeValue(args, i, arg)); options.configPath = takeValue(args, i, arg);
i += 1;
break;
case "--ui-bind-host":
options.uiBindHost = takeValue(args, i, arg);
i += 1; i += 1;
break; break;
default: default:
@@ -75,23 +58,73 @@ function parseInstallOptions(args: string[]): InstallOptions {
usage(); usage();
} }
for (const [name, value] of Object.entries({ port: options.port, sshPort: options.sshPort, uiPort: options.uiPort })) { return options;
if (!Number.isInteger(value) || value < 1 || value > 65535) { }
console.error(`Invalid ${name}: ${value}`);
function parseReconfigureOptions(args: string[]): ReconfigureOptions {
const options: ReconfigureOptions = {
packageDir: "",
configPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: false,
dryRun: false,
apply: false,
skipFirewall: false,
skipStart: false
};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
switch (arg) {
case "--package-dir":
options.packageDir = takeValue(args, i, arg);
i += 1;
break;
case "--config":
options.configPath = takeValue(args, i, arg);
i += 1;
break;
case "--dry-run":
options.dryRun = true;
break;
case "--apply":
options.apply = true;
break;
case "--skip-firewall":
options.skipFirewall = true;
break;
case "--skip-start":
options.skipStart = true;
break;
default:
console.error(`Unknown argument: ${arg}`);
usage(); usage();
} }
} }
if (!options.packageDir) {
console.error("Missing --package-dir");
usage();
}
if (options.dryRun === options.apply) {
console.error("Specify exactly one of --dry-run or --apply");
usage();
}
return options; return options;
} }
async function main(): Promise<void> { async function main(): Promise<void> {
const [command, ...args] = Bun.argv.slice(2); const [command, ...args] = Bun.argv.slice(2);
if (command !== "install") { if (command === "install") {
usage();
}
await install(parseInstallOptions(args)); await install(parseInstallOptions(args));
return;
}
if (command === "reconfigure") {
await reconfigure(parseReconfigureOptions(args));
return;
}
usage();
} }
main().catch((error: unknown) => { main().catch((error: unknown) => {
+12 -4
View File
@@ -1,7 +1,9 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { InstallContext, InstallOptions } from "../types/context"; import type { InstallContext, InstallOptions } from "../types/context";
import { readText } from "../lib/fs"; import { exists, readText, writeText } from "../lib/fs";
import { runVisible } from "../lib/process";
import { step } from "../lib/log"; import { step } from "../lib/log";
import { defaultRuntimeConfig, parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight"; import { preflight } from "../steps/preflight";
import { installDeps } from "../steps/deps"; import { installDeps } from "../steps/deps";
import { prepareFilesystem } from "../steps/filesystem"; import { prepareFilesystem } from "../steps/filesystem";
@@ -26,15 +28,16 @@ function secret(): string {
} }
export async function install(options: InstallOptions): Promise<void> { export async function install(options: InstallOptions): Promise<void> {
const hasConfig = await exists(options.configPath);
const config = hasConfig ? parseRuntimeEnv(await readText(options.configPath)) : defaultRuntimeConfig();
const context: InstallContext = { const context: InstallContext = {
options, options,
config,
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"), packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(), installDate: new Date().toISOString(),
hysteriaAuthPassword: secret(), hysteriaAuthPassword: secret(),
hysteriaObfsPassword: secret(),
hysteriaApiSecret: secret(),
hysteriaApiPort: 36712,
hysteriaVersion: "unknown" hysteriaVersion: "unknown"
}; };
@@ -44,6 +47,11 @@ export async function install(options: InstallOptions): Promise<void> {
await installDeps(context); await installDeps(context);
step("filesystem"); step("filesystem");
await prepareFilesystem(context); await prepareFilesystem(context);
if (!hasConfig) {
step("write runtime env");
await runVisible`mkdir -p /etc/hy2xs`;
await writeText(options.configPath, renderRuntimeEnv(config), 0o600);
}
step("bundled UI"); step("bundled UI");
await deployUi(context); await deployUi(context);
step("Hysteria2 upstream install"); step("Hysteria2 upstream install");
+76
View File
@@ -0,0 +1,76 @@
import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
import { readText, writeText } from "../lib/fs";
import { info, step } from "../lib/log";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight";
import { generateConfig } from "../steps/config";
import { deploySystemd } from "../steps/systemd";
import { applyFirewall } from "../steps/firewall";
import { writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke";
import { runVisible } from "../lib/process";
async function backupCurrentState(): Promise<void> {
await runVisible`mkdir -p /etc/hy2xs/backups`;
await runVisible`cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak 2>/dev/null || true`;
await runVisible`cp -a /etc/systemd/system/hy2xs-admin.service /etc/hy2xs/backups/hy2xs-admin.service.bak 2>/dev/null || true`;
await runVisible`cp -a /etc/systemd/system/hysteria-server.service /etc/hy2xs/backups/hysteria-server.service.bak 2>/dev/null || true`;
await runVisible`cp -a /etc/nftables.d/hy2xs.nft /etc/hy2xs/backups/hy2xs.nft.bak 2>/dev/null || true`;
}
async function rollbackCurrentState(): Promise<void> {
await runVisible`cp -a /etc/hy2xs/backups/config.yaml.bak /etc/hysteria/config.yaml 2>/dev/null || true`;
await runVisible`cp -a /etc/hy2xs/backups/hy2xs-admin.service.bak /etc/systemd/system/hy2xs-admin.service 2>/dev/null || true`;
await runVisible`cp -a /etc/hy2xs/backups/hysteria-server.service.bak /etc/systemd/system/hysteria-server.service 2>/dev/null || true`;
await runVisible`cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true`;
await runVisible`systemctl daemon-reload`;
await runVisible`systemctl restart hysteria-server hy2xs-admin || true`;
}
export async function reconfigure(options: ReconfigureOptions): Promise<void> {
const configRaw = await readText(options.configPath);
const config = parseRuntimeEnv(configRaw);
const context: ReconfigureContext & { packageVersion: string; packageBuildId: string; installDate: string; hysteriaAuthPassword: string; hysteriaVersion: string } = {
options,
config,
packageVersion: "reconfigure",
packageBuildId: "reconfigure",
installDate: new Date().toISOString(),
hysteriaAuthPassword: "managed-by-ui-auth",
hysteriaVersion: "unknown"
};
step("preflight");
await preflight(context);
if (options.dryRun) {
info("reconfigure dry-run: validated config and execution graph");
info(`config file: ${options.configPath}`);
info(`ui bind: ${config.uiBindHost}:${config.uiPort}`);
info(`hysteria bind: ${config.hysteriaBindHost}:${config.hysteriaPort}`);
info(`public endpoint: ${config.publicHost}:${config.publicPort}`);
return;
}
step("backup");
await backupCurrentState();
try {
step("config generation");
await generateConfig(context);
step("systemd units");
await deploySystemd(context);
step("firewall");
await applyFirewall(context);
step("write env artifacts");
await writeText(options.configPath, renderRuntimeEnv(config), 0o600);
await writePostInstallEnv(context);
step("smoke checks");
await smoke(context);
} catch (error) {
info("reconfigure failed, rollback in progress");
await rollbackCurrentState();
throw error;
}
}
+253
View File
@@ -0,0 +1,253 @@
import { randomBytes } from "node:crypto";
import type { RuntimeConfig, TlsMode } from "../types/context";
type EnvMap = Record<string, string>;
function randomSecret(): string {
return randomBytes(24).toString("base64url");
}
function parseEnv(content: string): EnvMap {
const result: EnvMap = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separator = line.indexOf("=");
if (separator < 1) {
throw new Error(`invalid env line: ${rawLine}`);
}
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
result[key] = value;
}
return result;
}
function parsePort(name: string, raw: string, fallback: number): number {
const value = raw ? Number(raw) : fallback;
if (!Number.isInteger(value) || value < 1 || value > 65535) {
throw new Error(`invalid ${name}: ${raw}`);
}
return value;
}
function parseBool(name: string, raw: string, fallback: boolean): boolean {
if (!raw) {
return fallback;
}
if (raw === "true") {
return true;
}
if (raw === "false") {
return false;
}
throw new Error(`invalid ${name}: ${raw}`);
}
function requireValue(name: string, value: string): string {
if (!value || !value.trim()) {
throw new Error(`missing required ${name}`);
}
return value.trim();
}
function normalizeIpv4Host(name: string, value: string): string {
if (value === "0.0.0.0" || value === "127.0.0.1") {
return value;
}
if (/^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(value)) {
return value;
}
if (value.includes(":")) {
throw new Error(`${name} must be IPv4-only`);
}
throw new Error(`invalid ${name}: ${value}`);
}
function normalizePublicHost(value: string): string {
if (!value) {
throw new Error("missing required HY2XS_PUBLIC_HOST");
}
if (value.includes(":")) {
throw new Error("HY2XS_PUBLIC_HOST must not contain IPv6");
}
return value;
}
function normalizeTlsMode(value: string): TlsMode {
if (value === "acme" || value === "file" || value === "self_signed_dev") {
return value;
}
throw new Error(`invalid HY2XS_TLS_MODE: ${value}`);
}
export function parseRuntimeEnv(content: string): RuntimeConfig {
const env = parseEnv(content);
const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080);
const uiBindHost = normalizeIpv4Host("HY2XS_UI_BIND_HOST", env.HY2XS_UI_BIND_HOST || "127.0.0.1");
const hysteriaPort = parsePort("HY2XS_HYSTERIA_PORT", env.HY2XS_HYSTERIA_PORT, 443);
const trafficStatsPort = parsePort("HY2XS_HYSTERIA_TRAFFIC_STATS_PORT", env.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT, 36712);
const tlsMode = normalizeTlsMode(env.HY2XS_TLS_MODE || "acme");
const config: RuntimeConfig = {
domain: env.HY2XS_DOMAIN || "",
publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""),
publicPort: parsePort("HY2XS_PUBLIC_PORT", env.HY2XS_PUBLIC_PORT, hysteriaPort),
ipv6Enabled: false,
sshPort: parsePort("HY2XS_SSH_PORT", env.HY2XS_SSH_PORT, 22),
firewallEnabled: parseBool("HY2XS_FIREWALL_ENABLED", env.HY2XS_FIREWALL_ENABLED, true),
firewallStagedApply: parseBool("HY2XS_FIREWALL_STAGED_APPLY", env.HY2XS_FIREWALL_STAGED_APPLY, true),
uiBindHost,
uiPort,
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
adminInitialPassword: env.HY2XS_ADMIN_INITIAL_PASSWORD || randomSecret(),
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, true),
tlsMode,
acmeEmail: env.HY2XS_ACME_EMAIL || "",
tlsCertPath: env.HY2XS_TLS_CERT_PATH || "/etc/hysteria/server.crt",
tlsKeyPath: env.HY2XS_TLS_KEY_PATH || "/etc/hysteria/server.key",
hysteriaBindHost: normalizeIpv4Host("HY2XS_HYSTERIA_BIND_HOST", env.HY2XS_HYSTERIA_BIND_HOST || "0.0.0.0"),
hysteriaPort,
hysteriaAuthMode: "http",
hysteriaAuthUrl: env.HY2XS_HYSTERIA_AUTH_URL || `http://127.0.0.1:${uiPort}/hui/hysteria2/auth`,
hysteriaTrafficStatsHost: normalizeIpv4Host(
"HY2XS_HYSTERIA_TRAFFIC_STATS_HOST",
env.HY2XS_HYSTERIA_TRAFFIC_STATS_HOST || "127.0.0.1"
),
hysteriaTrafficStatsPort: trafficStatsPort,
hysteriaTrafficStatsSecret: env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET || randomSecret(),
hysteriaObfsType: "salamander",
hysteriaObfsPassword: requireValue("HY2XS_HYSTERIA_OBFS_PASSWORD", env.HY2XS_HYSTERIA_OBFS_PASSWORD || randomSecret()),
hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
hysteriaIgnoreClientBandwidth: parseBool(
"HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH",
env.HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH,
false
),
hysteriaConfigPath: env.HY2XS_HYSTERIA_CONFIG_PATH || "/etc/hysteria/config.yaml",
hysteriaVersionPolicy: env.HY2XS_HYSTERIA_VERSION || "latest",
installDir: env.HY2XS_INSTALL_DIR || "/opt/hy2xs-admin",
dataDir: env.HY2XS_DATA_DIR || "/var/lib/hy2xs-admin",
logDir: env.HY2XS_LOG_DIR || "/var/log/hy2xs",
bootstrapAdminSecretPath: "/etc/hy2xs/bootstrap-admin.secret"
};
validateRuntimeConfig(config);
return config;
}
export function defaultRuntimeConfig(): RuntimeConfig {
return parseRuntimeEnv(
[
"HY2XS_IPV6_ENABLED=false",
"HY2XS_DOMAIN=",
"HY2XS_PUBLIC_HOST=127.0.0.1",
"HY2XS_PUBLIC_PORT=443",
"HY2XS_SSH_PORT=22",
"HY2XS_FIREWALL_ENABLED=true",
"HY2XS_FIREWALL_STAGED_APPLY=true",
"HY2XS_UI_BIND_HOST=127.0.0.1",
"HY2XS_UI_PORT=8080",
"HY2XS_ADMIN_USER=admin",
`HY2XS_ADMIN_INITIAL_PASSWORD=${randomSecret()}`,
"HY2XS_FORCE_PASSWORD_CHANGE=true",
"HY2XS_TLS_MODE=self_signed_dev",
"HY2XS_ACME_EMAIL=",
"HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt",
"HY2XS_TLS_KEY_PATH=/etc/hysteria/server.key",
"HY2XS_HYSTERIA_BIND_HOST=0.0.0.0",
"HY2XS_HYSTERIA_PORT=443",
"HY2XS_HYSTERIA_AUTH_MODE=http",
"HY2XS_HYSTERIA_AUTH_URL=http://127.0.0.1:8080/hui/hysteria2/auth",
"HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1",
"HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712",
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${randomSecret()}`,
"HY2XS_HYSTERIA_OBFS_TYPE=salamander",
`HY2XS_HYSTERIA_OBFS_PASSWORD=${randomSecret()}`,
"HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps",
"HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps",
"HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=false",
"HY2XS_HYSTERIA_CONFIG_PATH=/etc/hysteria/config.yaml",
"HY2XS_HYSTERIA_VERSION=latest",
"HY2XS_INSTALL_DIR=/opt/hy2xs-admin",
"HY2XS_DATA_DIR=/var/lib/hy2xs-admin",
"HY2XS_LOG_DIR=/var/log/hy2xs"
].join("\n")
);
}
export function validateRuntimeConfig(config: RuntimeConfig): void {
if (config.ipv6Enabled) {
throw new Error("HY2XS is IPv4-only: HY2XS_IPV6_ENABLED must be false");
}
if (config.uiBindHost.includes(":")) {
throw new Error("HY2XS_UI_BIND_HOST must be IPv4-only");
}
if (config.hysteriaBindHost.includes(":")) {
throw new Error("HY2XS_HYSTERIA_BIND_HOST must be IPv4-only");
}
if (config.hysteriaBindHost !== "0.0.0.0") {
throw new Error("HY2XS_HYSTERIA_BIND_HOST must be 0.0.0.0 in production profile");
}
if (config.tlsMode === "acme") {
if (!config.domain) {
throw new Error("HY2XS_DOMAIN is required for TLS mode acme");
}
if (!config.acmeEmail) {
throw new Error("HY2XS_ACME_EMAIL is required for TLS mode acme");
}
}
if (config.tlsMode === "file") {
requireValue("HY2XS_TLS_CERT_PATH", config.tlsCertPath);
requireValue("HY2XS_TLS_KEY_PATH", config.tlsKeyPath);
}
if (config.publicHost === "0.0.0.0") {
throw new Error("HY2XS_PUBLIC_HOST must be a routable domain or IPv4, not 0.0.0.0");
}
}
export function renderRuntimeEnv(config: RuntimeConfig): string {
const lines = [
"# HY2XS runtime config (editable)",
"HY2XS_IPV6_ENABLED=false",
`HY2XS_DOMAIN=${config.domain}`,
`HY2XS_PUBLIC_HOST=${config.publicHost}`,
`HY2XS_PUBLIC_PORT=${config.publicPort}`,
`HY2XS_SSH_PORT=${config.sshPort}`,
`HY2XS_FIREWALL_ENABLED=${config.firewallEnabled}`,
`HY2XS_FIREWALL_STAGED_APPLY=${config.firewallStagedApply}`,
`HY2XS_UI_BIND_HOST=${config.uiBindHost}`,
`HY2XS_UI_PORT=${config.uiPort}`,
`HY2XS_ADMIN_USER=${config.adminUser}`,
`HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`,
`HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`,
`HY2XS_TLS_MODE=${config.tlsMode}`,
`HY2XS_ACME_EMAIL=${config.acmeEmail}`,
`HY2XS_TLS_CERT_PATH=${config.tlsCertPath}`,
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`,
"HY2XS_HYSTERIA_AUTH_MODE=http",
`HY2XS_HYSTERIA_AUTH_URL=${config.hysteriaAuthUrl}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`,
"HY2XS_HYSTERIA_OBFS_TYPE=salamander",
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
`HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=${config.hysteriaIgnoreClientBandwidth}`,
`HY2XS_HYSTERIA_CONFIG_PATH=${config.hysteriaConfigPath}`,
`HY2XS_HYSTERIA_VERSION=${config.hysteriaVersionPolicy}`,
`HY2XS_INSTALL_DIR=${config.installDir}`,
`HY2XS_DATA_DIR=${config.dataDir}`,
`HY2XS_LOG_DIR=${config.logDir}`
];
return `${lines.join("\n")}\n`;
}
+31 -10
View File
@@ -3,19 +3,40 @@ import { readText, renderTemplate, writeText } from "../lib/fs";
import { runVisible } from "../lib/process"; import { runVisible } from "../lib/process";
export async function generateConfig(context: InstallContext): Promise<void> { export async function generateConfig(context: InstallContext): Promise<void> {
const tlsAcmeBlock = context.config.tlsMode === "acme"
? `acme:\n domains:\n - ${context.config.domain}\n email: ${context.config.acmeEmail}\n ca: letsencrypt\n dir: /var/lib/hysteria/acme\n listenHost: 0.0.0.0`
: "";
const tlsFileBlock = context.config.tlsMode === "file" || context.config.tlsMode === "self_signed_dev"
? `tls:\n cert: ${context.config.tlsCertPath}\n key: ${context.config.tlsKeyPath}`
: "";
const template = await readText(`${context.options.packageDir}/templates/hysteria/config.yaml.tpl`); const template = await readText(`${context.options.packageDir}/templates/hysteria/config.yaml.tpl`);
const rendered = renderTemplate(template, { const rendered = renderTemplate(template, {
HYSTERIA_PORT: context.options.port, HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
HYSTERIA_PORT: context.config.hysteriaPort,
HYSTERIA_AUTH_PASSWORD: context.hysteriaAuthPassword, HYSTERIA_AUTH_PASSWORD: context.hysteriaAuthPassword,
HYSTERIA_OBFS_PASSWORD: context.hysteriaObfsPassword, HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword,
HYSTERIA_API_PORT: context.hysteriaApiPort, HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost,
HYSTERIA_API_SECRET: context.hysteriaApiSecret, HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort,
UI_PORT: context.options.uiPort, HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret,
BANDWIDTH_UP: "50 mbps", UI_PORT: context.config.uiPort,
BANDWIDTH_DOWN: "50 mbps" BANDWIDTH_UP: context.config.hysteriaBandwidthUp,
BANDWIDTH_DOWN: context.config.hysteriaBandwidthDown,
TLS_ACME_BLOCK: tlsAcmeBlock,
TLS_FILE_BLOCK: tlsFileBlock,
AUTH_INSECURE: context.config.tlsMode === "self_signed_dev" ? "true" : "false"
}); });
await writeText("/etc/hysteria/config.yaml", rendered, 0o600); await writeText("/etc/hysteria/config.yaml.tmp", rendered, 0o600);
await runVisible`openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj /CN=${context.options.domain || "hy2xs.local"} -keyout /etc/hysteria/server.key -out /etc/hysteria/server.crt`; await runVisible`mv /etc/hysteria/config.yaml.tmp /etc/hysteria/config.yaml`;
await runVisible`chown hysteria:hysteria /etc/hysteria/config.yaml /etc/hysteria/server.key /etc/hysteria/server.crt`;
if (context.config.tlsMode === "self_signed_dev") {
await runVisible`openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj /CN=${context.config.domain || "hy2xs.local"} -keyout ${context.config.tlsKeyPath} -out ${context.config.tlsCertPath}`;
await runVisible`chmod 600 ${context.config.tlsKeyPath} ${context.config.tlsCertPath}`;
}
await runVisible`chown hysteria:hysteria /etc/hysteria/config.yaml`;
if (context.config.tlsMode !== "acme") {
await runVisible`chown hysteria:hysteria ${context.config.tlsKeyPath} ${context.config.tlsCertPath}`;
}
} }
+21 -7
View File
@@ -6,15 +6,29 @@ export async function writePostInstallEnv(context: InstallContext): Promise<void
PACKAGE_VERSION: context.packageVersion, PACKAGE_VERSION: context.packageVersion,
PACKAGE_BUILD_ID: context.packageBuildId, PACKAGE_BUILD_ID: context.packageBuildId,
INSTALL_DATE: context.installDate, INSTALL_DATE: context.installDate,
DOMAIN: context.options.domain, DOMAIN: context.config.domain,
SSH_PORT: context.options.sshPort, PUBLIC_HOST: context.config.publicHost,
PUBLIC_PORT: context.config.publicPort,
SSH_PORT: context.config.sshPort,
FIREWALL_ENABLED: context.config.firewallEnabled ? "true" : "false",
FIREWALL_STAGED_APPLY: context.config.firewallStagedApply ? "true" : "false",
HYSTERIA_VERSION: context.hysteriaVersion, HYSTERIA_VERSION: context.hysteriaVersion,
HYSTERIA_PORT: context.options.port, TLS_MODE: context.config.tlsMode,
HYSTERIA_OBFS_PASSWORD: context.hysteriaObfsPassword, ACME_EMAIL: context.config.acmeEmail,
HYSTERIA_API_PORT: context.hysteriaApiPort, TLS_CERT_PATH: context.config.tlsCertPath,
UI_BIND_HOST: context.options.uiBindHost, TLS_KEY_PATH: context.config.tlsKeyPath,
UI_PORT: context.options.uiPort HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
HYSTERIA_PORT: context.config.hysteriaPort,
HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword,
HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost,
HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort,
UI_BIND_HOST: context.config.uiBindHost,
UI_PORT: context.config.uiPort,
INSTALL_DIR: context.config.installDir,
DATA_DIR: context.config.dataDir,
LOG_DIR: context.config.logDir
}); });
await writeText("/etc/hysteria/post-install.env", rendered, 0o600); await writeText("/etc/hysteria/post-install.env", rendered, 0o600);
await writeText(context.config.bootstrapAdminSecretPath, `${context.config.adminUser}:${context.config.adminInitialPassword}\n`, 0o600);
} }
+7 -3
View File
@@ -1,9 +1,13 @@
import type { InstallContext } from "../types/context"; import type { InstallContext } from "../types/context";
import { runVisible } from "../lib/process"; import { runVisible } from "../lib/process";
export async function prepareFilesystem(_context: InstallContext): Promise<void> { export async function prepareFilesystem(context: InstallContext): Promise<void> {
await runVisible`id -u hysteria >/dev/null 2>&1 || useradd --system --home /var/lib/hysteria --shell /usr/sbin/nologin hysteria`; await runVisible`id -u hysteria >/dev/null 2>&1 || useradd --system --home /var/lib/hysteria --shell /usr/sbin/nologin hysteria`;
await runVisible`mkdir -p /etc/hysteria /var/lib/hysteria /opt/hy2xs-admin /var/lib/hy2xs-admin /var/log/hy2xs /usr/local/lib/hy2xs`; await runVisible`id -u hy2xs-admin >/dev/null 2>&1 || useradd --system --home ${context.config.dataDir} --shell /usr/sbin/nologin hy2xs-admin`;
await runVisible`mkdir -p /etc/hy2xs /etc/hysteria /var/lib/hysteria ${context.config.installDir} ${context.config.dataDir} ${context.config.logDir} /usr/local/lib/hy2xs /etc/nftables.d`;
await runVisible`chown -R hysteria:hysteria /etc/hysteria /var/lib/hysteria`; await runVisible`chown -R hysteria:hysteria /etc/hysteria /var/lib/hysteria`;
await runVisible`chown -R root:root /var/lib/hy2xs-admin`; await runVisible`chown -R root:root ${context.config.installDir}`;
await runVisible`chmod -R go-w ${context.config.installDir}`;
await runVisible`chown -R hy2xs-admin:hy2xs-admin ${context.config.dataDir} ${context.config.logDir}`;
await runVisible`chmod 700 /etc/hy2xs`;
} }
+23 -6
View File
@@ -4,19 +4,36 @@ import { info } from "../lib/log";
import { runVisible } from "../lib/process"; import { runVisible } from "../lib/process";
export async function applyFirewall(context: InstallContext): Promise<void> { export async function applyFirewall(context: InstallContext): Promise<void> {
if (context.options.skipFirewall) { if (context.options.skipFirewall || !context.config.firewallEnabled) {
info("firewall skipped by flag"); info("firewall skipped by flag");
return; return;
} }
const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/nftables/hy2xs.nft.tpl`), { const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/nftables/hy2xs.nft.tpl`), {
SSH_PORT: context.options.sshPort, SSH_PORT: context.config.sshPort,
HYSTERIA_PORT: context.options.port, HYSTERIA_PORT: context.config.hysteriaPort,
UI_PORT: context.options.uiPort UI_PORT: context.config.uiPort,
UI_BIND_HOST: context.config.uiBindHost
}); });
await runVisible`cp -a /etc/nftables.conf /etc/nftables.conf.hy2xs.bak 2>/dev/null || true`; await runVisible`cp -a /etc/nftables.d/hy2xs.nft /etc/nftables.d/hy2xs.nft.bak 2>/dev/null || true`;
await writeText("/etc/nftables.conf", rendered, 0o644); await writeText("/etc/nftables.d/hy2xs.nft.candidate", rendered, 0o600);
await runVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`;
if (context.config.firewallStagedApply) {
await runVisible`systemd-run --unit hy2xs-fw-rollback --on-active=45s /bin/sh -c 'cp -a /etc/nftables.d/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; nft -f /etc/nftables.conf >/dev/null 2>&1 || true'`;
}
await runVisible`mv /etc/nftables.d/hy2xs.nft.candidate /etc/nftables.d/hy2xs.nft`;
await runVisible`grep -q 'include "/etc/nftables.d/hy2xs.nft"' /etc/nftables.conf || printf '\ninclude "/etc/nftables.d/hy2xs.nft"\n' >> /etc/nftables.conf`;
await runVisible`nft -f /etc/nftables.conf`; await runVisible`nft -f /etc/nftables.conf`;
await runVisible`systemctl enable --now nftables`; await runVisible`systemctl enable --now nftables`;
await runVisible`ss -H -ltn | grep -q ':${context.config.sshPort} ' || (echo 'ssh port check failed' >&2; exit 1)`;
await runVisible`ss -H -lun | grep -q ':${context.config.hysteriaPort} ' || (echo 'hysteria udp port check failed' >&2; exit 1)`;
if (context.config.firewallStagedApply) {
await runVisible`systemctl stop hy2xs-fw-rollback || true`;
await runVisible`systemctl reset-failed hy2xs-fw-rollback || true`;
}
} }
+42 -9
View File
@@ -1,6 +1,6 @@
import type { InstallContext } from "../types/context"; import type { InstallContext } from "../types/context";
import { exists, readText } from "../lib/fs"; import { exists, readText } from "../lib/fs";
import { fail } from "../lib/log"; import { fail, info } from "../lib/log";
import { run } from "../lib/process"; import { run } from "../lib/process";
async function isPortBusy(port: number): Promise<boolean> { async function isPortBusy(port: number): Promise<boolean> {
@@ -13,6 +13,8 @@ async function isPortBusy(port: number): Promise<boolean> {
} }
export async function preflight(context: InstallContext): Promise<void> { export async function preflight(context: InstallContext): Promise<void> {
const isReconfigure = context.packageVersion === "reconfigure";
if (process.getuid?.() !== 0) { if (process.getuid?.() !== 0) {
fail("installer must run as root"); fail("installer must run as root");
} }
@@ -26,28 +28,59 @@ export async function preflight(context: InstallContext): Promise<void> {
fail("bundled HY2XS admin is missing from install package"); fail("bundled HY2XS admin is missing from install package");
} }
if (await exists("/etc/hysteria/post-install.env")) { if (!isReconfigure && (await exists("/etc/hysteria/post-install.env"))) {
fail("existing HY2XS post-install.env found; update/repair is out of scope"); fail("existing HY2XS post-install.env found; update/repair is out of scope");
} }
if (await exists("/opt/hy2xs-admin")) { if (!isReconfigure && (await exists(context.config.installDir))) {
fail("existing /opt/hy2xs-admin found; conflicting old state"); fail("existing /opt/hy2xs-admin found; conflicting old state");
} }
const ports = new Set([context.options.port, context.options.uiPort]); const ports = new Set([context.config.hysteriaPort, context.config.uiPort]);
if (ports.size !== 2) { if (ports.size !== 2) {
fail("Hysteria port and UI port must be different"); fail("Hysteria port and UI port must be different");
} }
if (context.options.domain && !/^[a-zA-Z0-9.-]+$/.test(context.options.domain)) { if (context.config.domain && !/^[a-zA-Z0-9.-]+$/.test(context.config.domain)) {
fail("domain contains unsupported characters"); fail("domain contains unsupported characters");
} }
if (await isPortBusy(context.options.port)) { if (context.config.uiBindHost.includes(":")) {
fail(`Hysteria UDP/TCP port already appears to be in use: ${context.options.port}`); fail("HY2XS UI bind host must be IPv4-only");
} }
if (await isPortBusy(context.options.uiPort)) { if (context.config.hysteriaBindHost !== "0.0.0.0") {
fail(`HY2XS admin port already appears to be in use: ${context.options.uiPort}`); fail("HY2XS_HYSTERIA_BIND_HOST must be 0.0.0.0 for production profile");
}
if (context.config.tlsMode === "acme" && (!context.config.domain || !context.config.acmeEmail)) {
fail("acme mode requires HY2XS_DOMAIN and HY2XS_ACME_EMAIL");
}
if (context.config.domain) {
try {
const a = await run`getent ahostsv4 ${context.config.domain}`;
if (!a.trim()) {
fail(`domain has no A-record: ${context.config.domain}`);
}
} catch {
fail(`domain has no A-record: ${context.config.domain}`);
}
try {
const aaaa = await run`getent ahostsv6 ${context.config.domain}`;
if (aaaa.trim()) {
info(`warning: domain ${context.config.domain} has AAAA record; HY2XS remains IPv4-only`);
}
} catch {
// no AAAA is acceptable
}
}
if (await isPortBusy(context.config.hysteriaPort)) {
fail(`Hysteria UDP/TCP port already appears to be in use: ${context.config.hysteriaPort}`);
}
if (await isPortBusy(context.config.uiPort)) {
fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`);
} }
} }

Some files were not shown because too many files have changed in this diff Show More