commit 84a4e94567345716dc51ccac8bb6b72c3e69fab5 Author: Crimson Date: Sat Apr 25 23:13:12 2026 +0500 Подготовить HY2XS к production-сборке diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f190b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +dist/ +builder/output/ +tools/build/output/ +.toolchain/ +orchestrator/dist/ +orchestrator/node_modules/ +apps/frontend/node_modules/ +apps/frontend/dist/* +!apps/frontend/dist/.embed-placeholder +apps/data/ +apps/logs/ +apps/bin/ +apps/export/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c530427 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# HY2XS + +HY2XS is a two-layer install package for a clean Debian 12 target: + +- local builder layer: creates one transferable install archive +- install-only Bun/TypeScript orchestrator: runs on the target as a compiled artifact +- bundled HY2XS admin fork: shipped inside the package +- vanilla Hysteria2: downloaded from the official upstream during install + +The baseline deliberately excludes target-side builds, update, rollback, uninstall, Telegram bot delivery, billing, and external access/profile delivery. + +## Repository Layout + +- `builder/` - local shell-first packaging pipeline +- `orchestrator/` - Bun + TypeScript install-only orchestrator sources +- `package/` - package skeleton: thin entrypoint, templates, systemd units, package docs +- `apps/` - current HY2XS admin fork source +- `docs/` - baseline architecture and acceptance docs +- `hy2xs_implementation_plan-no_git/` - implementation roadmap and task breakdown +- `dist/` - final install archives generated by the builder + +## Build + +On the local build machine: + +```sh +PACKAGE_VERSION=0.1.0 ./builder/build.sh +``` + +If tools are not in `PATH`, pass explicit binaries: + +```sh +GO_BIN=/path/to/go PNPM_BIN=/path/to/pnpm BUN_BIN=/path/to/bun PACKAGE_VERSION=0.1.0 ./builder/build.sh +``` + +On the current Windows workstation the known tool paths are: + +```powershell +$env:PATH = "E:\Git\cmd;E:\go\bin;" + $env:PATH +E:\go\bin\go.exe test ./... +pnpm.cmd install --frozen-lockfile +``` + +The expected result is: + +```text +dist/hy2xs-install-0.1.0.tar.gz +``` + +The target server must not run `bun install`, `pnpm install`, TypeScript transpilation, frontend builds, or Go builds. + +## Publication + +Target repository: + +```text +https://git.ext.flamy.studio/flamy_dev/HY2XS_flamy.git +``` + +Publish only after the full baseline acceptance is complete, including the Bun/TypeScript orchestrator artifact build and final package smoke checks. + +## Install + +On a clean Debian 12 target, unpack the archive and run as root: + +```sh +./install.sh --non-interactive --domain example.com +``` + +Useful baseline flags: + +```sh +./install.sh --non-interactive --domain example.com --port 443 --ssh-port 22 --ui-port 8080 +``` + +The install flow writes `/etc/hysteria/post-install.env` with the resulting package, Hysteria, port, and path state. diff --git a/apps/.gitattributes b/apps/.gitattributes new file mode 100644 index 0000000..ddc023e --- /dev/null +++ b/apps/.gitattributes @@ -0,0 +1,5 @@ +*.ts linguist-language=Go +*.js linguist-language=Go +*.css linguist-language=Go +*.scss linguist-language=Go +*.html linguist-language=Go \ No newline at end of file diff --git a/apps/.gitignore b/apps/.gitignore new file mode 100644 index 0000000..4c0918a --- /dev/null +++ b/apps/.gitignore @@ -0,0 +1,6 @@ +/.idea +/bin +/build +/data +/export +/logs \ No newline at end of file diff --git a/apps/cmd/cmd.go b/apps/cmd/cmd.go new file mode 100644 index 0000000..cb90da0 --- /dev/null +++ b/apps/cmd/cmd.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "github.com/spf13/cobra" + "hy2xs-admin/model/constant" + "hy2xs-admin/util" + "os" +) + +var rootCmd = &cobra.Command{ + Use: "hy2xs-admin", + Short: "HY2XS admin panel for Hysteria2", + Long: "HY2XS admin panel for Hysteria2.", + Run: run, +} + +var ( + version bool + port string +) + +func init() { + rootCmd.Flags().BoolVarP(&version, "version", "v", false, "Show version") + rootCmd.Flags().StringVarP(&port, "port", "p", "", "The port of the web server") +} + +func run(cmd *cobra.Command, args []string) { + if version { + fmt.Println("HY2XS admin version", constant.Version) + return + } + + if err := util.VerifyPort(port); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + for { + if err := runServer(port); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + } +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/apps/cmd/reset.go b/apps/cmd/reset.go new file mode 100644 index 0000000..fcd8fb7 --- /dev/null +++ b/apps/cmd/reset.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "fmt" + "github.com/spf13/cobra" + "hy2xs-admin/dao" + "hy2xs-admin/util" + "os" +) + +var resetCmd = &cobra.Command{ + Use: "reset", + Short: "Reset username and password", + Long: "Reset username and password.", + Run: runReset, +} + +func init() { + rootCmd.AddCommand(resetCmd) +} + +func runReset(cmd *cobra.Command, args []string) { + username, err := util.RandomString(6) + if err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + password, err := util.RandomString(6) + if err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + if err = dao.InitSqliteDB(); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + if err = dao.UpdateAccount([]int64{1}, map[string]interface{}{ + "username": username, + "pass": util.SHA224String(password), + "con_pass": fmt.Sprintf("%s.%s", username, password)}); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + if err = dao.CloseSqliteDB(); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + fmt.Println(fmt.Sprintf("HY2XS admin Login Username: %s", username)) + 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))) +} diff --git a/apps/cmd/server.go b/apps/cmd/server.go new file mode 100644 index 0000000..ff5e8fa --- /dev/null +++ b/apps/cmd/server.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "errors" + "fmt" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "hy2xs-admin/dao" + "hy2xs-admin/middleware" + "hy2xs-admin/model/constant" + "hy2xs-admin/router" + "hy2xs-admin/service" + "hy2xs-admin/util" + "net/http" + "os" +) + +func runServer(port string) error { + defer releaseResource() + + middleware.InitLog() + service.InitForward() + if err := initFile(); err != nil { + return err + } + if err := dao.InitSql(port); err != nil { + return err + } + if err := middleware.InitCron(); err != nil { + return err + } + if err := service.InitHysteria2(); err != nil { + return err + } + 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) + if err != nil { + return err + } + + r := gin.Default() + router.Router(r, config.Value) + + serverPort, crtPath, keyPath, err := service.GetServerPortAndCert() + if err != nil { + return err + } + + service.InitServer(fmt.Sprintf(":%d", serverPort), r) + if err := service.StartServer(crtPath, keyPath); err != nil && err != http.ErrServerClosed { + logrus.Errorf("start server err: %v", err) + return errors.New("start server err") + } + return nil +} + +func releaseResource() { + if err := dao.CloseSqliteDB(); err != nil { + logrus.Errorf(err.Error()) + } + if err := service.ReleaseHysteria2(); err != nil { + logrus.Errorf(err.Error()) + } + if err := service.RemoveByComment(); err != nil { + logrus.Errorf(err.Error()) + } +} + +func initFile() error { + var dirs = []string{constant.LogDir, constant.SqliteDBDir, constant.BinDir, constant.ExportPathDir} + for _, item := range dirs { + if !util.Exists(item) { + if err := os.Mkdir(item, os.ModePerm); err != nil { + logrus.Errorf("%s create err: %v", item, err) + return errors.New(fmt.Sprintf("%s create err", item)) + } + } + } + return nil +} diff --git a/apps/cmd/version.go b/apps/cmd/version.go new file mode 100644 index 0000000..f265e02 --- /dev/null +++ b/apps/cmd/version.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "fmt" + "github.com/spf13/cobra" + "hy2xs-admin/model/constant" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Show version", + Long: "Show version.", + Run: runVersion, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + +func runVersion(cmd *cobra.Command, args []string) { + fmt.Println("HY2XS admin version", constant.Version) +} diff --git a/apps/controller/account.go b/apps/controller/account.go new file mode 100644 index 0000000..59df427 --- /dev/null +++ b/apps/controller/account.go @@ -0,0 +1,335 @@ +package controller + +import ( + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/dto" + "hy2xs-admin/model/entity" + "hy2xs-admin/model/vo" + "hy2xs-admin/service" + "hy2xs-admin/util" + "io" + "strings" + "time" +) + +func Login(c *gin.Context) { + loginDto, err := validateField(c, dto.LoginDto{}) + if err != nil { + return + } + + if !service.ExistAccountUsername(*loginDto.Username, 0) { + vo.Fail("account not exist", c) + return + } + + token, err := service.Login(*loginDto.Username, util.SHA224String(*loginDto.Pass)) + if err != nil { + vo.Fail(err.Error(), c) + return + } + jwtVo := vo.JwtVo{ + TokenType: constant.TokenType, + AccessToken: token, + } + vo.Success(jwtVo, c) +} + +func PageAccount(c *gin.Context) { + accountPageDto, err := validateField(c, dto.AccountPageDto{}) + if err != nil { + return + } + accounts, total, err := service.PageAccount(accountPageDto) + if err != nil { + vo.Fail(err.Error(), c) + return + } + + onlineUsers, err := service.Hysteria2Online() + if err != nil { + vo.Fail(err.Error(), c) + return + } + + var accountVos []vo.AccountVo + for _, item := range accounts { + accountVo := vo.AccountVo{ + Username: *item.Username, + Quota: *item.Quota, + Download: *item.Download, + Upload: *item.Upload, + ExpireTime: *item.ExpireTime, + KickUtilTime: *item.KickUtilTime, + DeviceNo: *item.DeviceNo, + Role: *item.Role, + Deleted: *item.Deleted, + BaseVo: vo.BaseVo{ + Id: *item.Id, + CreateTime: *item.CreateTime, + }, + LoginAt: *item.LoginAt, + ConAt: *item.ConAt, + Remark: *item.Remark, + } + if value, exists := onlineUsers[*item.Username]; exists { + accountVo.Online = true + accountVo.Device = value + delete(onlineUsers, *item.Username) + } + accountVos = append(accountVos, accountVo) + } + accountPageVo := vo.AccountPageVo{ + AccountVos: accountVos, + Total: total, + } + vo.Success(accountPageVo, c) +} + +func SaveAccount(c *gin.Context) { + accountSaveDto, err := validateField(c, dto.AccountSaveDto{}) + if err != nil { + return + } + + if service.ExistAccountUsername(*accountSaveDto.Username, 0) { + vo.Fail(fmt.Sprintf("username %s already exists", *accountSaveDto.Username), c) + return + } + + passEncrypt := util.SHA224String(*accountSaveDto.Pass) + conPass := fmt.Sprintf("%s.%s", *accountSaveDto.Username, *accountSaveDto.ConPass) + account := entity.Account{ + Username: accountSaveDto.Username, + Pass: &passEncrypt, + ConPass: &conPass, + Quota: accountSaveDto.Quota, + ExpireTime: accountSaveDto.ExpireTime, + DeviceNo: accountSaveDto.DeviceNo, + Deleted: accountSaveDto.Deleted, + Remark: accountSaveDto.Remark, + } + err = service.SaveAccount(account) + if err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(nil, c) +} + +func DeleteAccount(c *gin.Context) { + idDto, err := validateField(c, dto.IdDto{}) + if err != nil { + return + } + account, err := service.GetAccount(*idDto.Id) + if err != nil { + vo.Fail(err.Error(), c) + return + } + if *account.Role == "admin" { + vo.Fail("admin cannot be deleted", c) + return + } + err = service.DeleteAccount([]int64{*idDto.Id}) + if err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(nil, c) +} + +func UpdateAccount(c *gin.Context) { + accountUpdateDto, err := validateField(c, dto.AccountUpdateDto{}) + if err != nil { + return + } + + if accountUpdateDto.Username != nil && *accountUpdateDto.Username != "" && service.ExistAccountUsername(*accountUpdateDto.Username, *accountUpdateDto.Id) { + vo.Fail(fmt.Sprintf("username %s already exists", *accountUpdateDto.Username), c) + return + } + + if accountUpdateDto.Deleted != nil && *accountUpdateDto.Deleted == 1 { + account, err := service.GetAccount(*accountUpdateDto.Id) + if err != nil { + vo.Fail(err.Error(), c) + return + } + if *account.Role == "admin" { + vo.Fail("the admin account cannot be deleted", c) + return + } + } + + var passEncrypt *string + if accountUpdateDto.Pass != nil && *accountUpdateDto.Pass != "" { + passEncryptSha224 := util.SHA224String(*accountUpdateDto.Pass) + passEncrypt = &passEncryptSha224 + } + + account := entity.Account{ + Username: accountUpdateDto.Username, + Pass: passEncrypt, + ConPass: accountUpdateDto.ConPass, + Quota: accountUpdateDto.Quota, + ExpireTime: accountUpdateDto.ExpireTime, + DeviceNo: accountUpdateDto.DeviceNo, + Deleted: accountUpdateDto.Deleted, + Remark: accountUpdateDto.Remark, + BaseEntity: entity.BaseEntity{ + Id: accountUpdateDto.Id, + }, + } + if err = service.UpdateAccount(account); err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(nil, c) +} + +func ResetTraffic(c *gin.Context) { + idDto, err := validateField(c, dto.IdDto{}) + if err != nil { + return + } + if err = service.ResetTraffic(*idDto.Id); err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(nil, c) +} + +func GetAccountInfo(c *gin.Context) { + accountInfoVo, err := service.GetAccountInfo(c) + if err != nil { + vo.Fail(err.Error(), c) + return + } + // 更新最近登录时间 + now := time.Now().UnixMilli() + if err = service.UpdateAccount(entity.Account{ + BaseEntity: entity.BaseEntity{Id: &accountInfoVo.Id}, + LoginAt: &now, + }); err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(accountInfoVo, c) +} + +func GetAccount(c *gin.Context) { + idDto, err := validateField(c, dto.IdDto{}) + if err != nil { + return + } + account, err := service.GetAccount(*idDto.Id) + if err != nil { + vo.Fail(err.Error(), c) + return + } + accountVo := vo.AccountVo{ + BaseVo: vo.BaseVo{ + Id: *account.Id, + CreateTime: *account.CreateTime, + }, + Username: *account.Username, + Quota: *account.Quota, + Download: *account.Download, + Upload: *account.Upload, + ExpireTime: *account.ExpireTime, + DeviceNo: *account.DeviceNo, + Role: *account.Role, + Deleted: *account.Deleted, + Remark: *account.Remark, + } + vo.Success(accountVo, c) +} + +func ImportAccount(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + vo.Fail(constant.SysError, c) + return + } + // 文件大小 2MB + if header.Size > 1024*1024*2 { + vo.Fail("the file is too big", c) + return + } + // 文件后缀.json + if !strings.HasSuffix(header.Filename, ".json") { + vo.Fail("file format not supported", c) + return + } + content, err := io.ReadAll(file) + if err != nil { + vo.Fail("json file read err", c) + return + } + var accounts []entity.Account + if err = json.Unmarshal(content, &accounts); err != nil { + vo.Fail("content Unmarshal err", c) + return + } + if err = service.UpsertAccount(accounts); err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(nil, c) +} + +func ExportAccount(c *gin.Context) { + accountExports, err := service.ListExportAccount() + if err != nil { + vo.Fail(err.Error(), c) + return + } + + fileName := fmt.Sprintf("AccountExport-%s.json", time.Now().Format("20060102150405")) + filePath := constant.ExportPathDir + fileName + + if err = util.ExportFile(filePath, accountExports, 0); err != nil { + vo.Fail(err.Error(), c) + return + } + + // 下载 + if !util.Exists(filePath) { + vo.Fail("file not exist", c) + return + } + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + c.File(filePath) +} + +func ReleaseKickAccount(c *gin.Context) { + idDto, err := validateField(c, dto.IdDto{}) + if err != nil { + return + } + if err = service.ReleaseKickAccount(*idDto.Id); err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(nil, c) +} + +func VerifyDefaultPass(c *gin.Context) { + info, err := service.GetAccountInfo(c) + if err != nil { + vo.Fail(err.Error(), c) + return + } + account, err := service.GetAccount(info.Id) + if err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(account.Pass != nil && *account.Pass == "02f382b76ca1ab7aa06ab03345c7712fd5b971fb0c0f2aef98bac9cd", c) +} diff --git a/apps/controller/config.go b/apps/controller/config.go new file mode 100644 index 0000000..a93c492 --- /dev/null +++ b/apps/controller/config.go @@ -0,0 +1,503 @@ +package controller + +import ( + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + "hy2xs-admin/dao" + "hy2xs-admin/model/bo" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/dto" + "hy2xs-admin/model/entity" + "hy2xs-admin/model/vo" + "hy2xs-admin/service" + "hy2xs-admin/util" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +func UpdateConfigs(c *gin.Context) { + configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{}) + if err != nil { + return + } + + port, crtPath, keyPath, err := service.GetPortAndCert() + if err != nil { + vo.Fail(err.Error(), c) + return + } + + needResetPortHopping := false + needRestart := false + + for _, item := range configsUpdateDto.ConfigUpdateDtos { + key := *item.Key + value := *item.Value + + if key == constant.HUIWebPort && strconv.FormatInt(port, 10) != value { + port, err := strconv.Atoi(value) + if err != nil { + vo.Fail(fmt.Sprintf("port: %s is invalid", value), c) + return + } + if !util.IsPortAvailable(uint(port), "tcp") { + vo.Fail(fmt.Sprintf("port: %s is used", value), c) + return + } + needRestart = true + } + if key == constant.HUICrtPath && crtPath != value { + if value != "" && !util.Exists(value) { + vo.Fail(fmt.Sprintf("crt path: %s is not exist", value), c) + return + } + needRestart = true + } + if key == constant.HUIKeyPath && keyPath != value { + if value != "" && !util.Exists(value) { + vo.Fail(fmt.Sprintf("key path: %s is not exist", value), c) + return + } + needRestart = true + } + + if key == constant.HUIWebContext { + huiWebContext, err := service.GetConfig(constant.HUIWebContext) + if err != nil { + vo.Fail(err.Error(), c) + return + } + if *huiWebContext.Value != value { + needRestart = true + } + } + + if key == constant.Hysteria2ConfigPortHopping { + re := regexp.MustCompile(`^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$`) + if value != "" && !re.MatchString(value) { + vo.Fail(fmt.Sprintf("port hopping: %s is invalid", value), c) + 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 { + resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron) + if err != nil { + vo.Fail(err.Error(), c) + return + } + if *resetTrafficCron.Value != value { + needRestart = true + } + } + + if err = service.UpdateConfig(key, value); err != nil { + vo.Fail(err.Error(), c) + return + } + } + + if needResetPortHopping { + if err := service.InitPortHopping(); err != nil { + vo.Fail(err.Error(), c) + return + } + } + + if needRestart { + go func() { + _ = service.StopServer() + }() + } + + vo.Success(nil, c) +} + +func GetConfig(c *gin.Context) { + configDto, err := validateField(c, dto.ConfigDto{}) + if err != nil { + return + } + config, err := service.GetConfig(*configDto.Key) + if err != nil { + vo.Fail(err.Error(), c) + return + } + configVo := vo.ConfigVo{ + Key: *config.Key, + Value: *config.Value, + } + + running := service.Hysteria2IsRunning() + + if (*config.Value == "1") != running { + enable := "0" + if running { + enable = "1" + } + if err := service.UpdateConfig(constant.Hysteria2Enable, enable); err != nil { + vo.Fail(err.Error(), c) + return + } + configVo.Value = enable + } + + vo.Success(configVo, c) +} + +func ListConfig(c *gin.Context) { + configsDto, err := validateField(c, dto.ConfigsDto{}) + if err != nil { + return + } + configs, err := service.ListConfig(configsDto.Keys) + if err != nil { + vo.Fail(err.Error(), c) + return + } + var configVos []vo.ConfigVo + for _, item := range configs { + configVo := vo.ConfigVo{ + Key: *item.Key, + Value: *item.Value, + } + configVos = append(configVos, configVo) + } + vo.Success(configVos, c) +} + +func GetHysteria2Config(c *gin.Context) { + config, err := service.GetHysteria2Config() + if err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(config, c) +} + +func UpdateHysteria2Config(c *gin.Context) { + hysteria2ServerConfig, err := validateField(c, bo.Hysteria2ServerConfig{}) + if err != nil { + 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 { + vo.Fail(err.Error(), c) + return + } + + if needResetPortHopping { + if err := service.InitPortHopping(); err != nil { + vo.Fail(err.Error(), c) + return + } + } + + running := service.Hysteria2IsRunning() + if running { + if err = service.RestartHysteria2(); err != nil { + vo.Fail(err.Error(), c) + return + } + } + vo.Success(nil, c) +} + +func ExportHysteria2Config(c *gin.Context) { + hysteria2ServerConfig, err := service.GetHysteria2Config() + if err != nil { + vo.Fail(err.Error(), c) + return + } + + // 默认值 + config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.JwtSecret}) + if err != nil { + vo.Fail(err.Error(), c) + return + } + + var hUIWebPort string + var jwtSecret string + for _, item := range config { + if *item.Key == constant.HUIWebPort { + hUIWebPort = *item.Value + } else if *item.Key == constant.JwtSecret { + jwtSecret = *item.Value + } + } + + if hUIWebPort == "" || jwtSecret == "" { + logrus.Errorf("hUIWebPort or jwtSecret is nil") + vo.Fail(constant.SysError, c) + return + } + + authHttpUrl, err := service.GetAuthHttpUrl() + if err != nil { + vo.Fail(err.Error(), c) + return + } + + authType := "http" + authHttpInsecure := true + var auth bo.ServerConfigAuth + auth.Type = &authType + var http bo.ServerConfigAuthHTTP + http.URL = &authHttpUrl + http.Insecure = &authHttpInsecure + auth.HTTP = &http + hysteria2ServerConfig.Auth = &auth + hysteria2ServerConfig.TrafficStats.Secret = &jwtSecret + + fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405")) + filePath := constant.ExportPathDir + fileName + + if err = util.ExportFile(filePath, hysteria2ServerConfig, 1); err != nil { + vo.Fail(err.Error(), c) + return + } + + if !util.Exists(filePath) { + vo.Fail("file not exist", c) + return + } + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + c.File(filePath) +} + +func ImportHysteria2Config(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + vo.Fail(constant.SysError, c) + return + } + if header.Size > 1024*1024*2 { + vo.Fail("the file is too big", c) + return + } + if !strings.HasSuffix(header.Filename, ".yaml") { + vo.Fail("file format not supported", c) + return + } + content, err := io.ReadAll(file) + if err != nil { + vo.Fail("yaml file read err", c) + return + } + var hysteria2ServerConfig bo.Hysteria2ServerConfig + if err = yaml.Unmarshal(content, &hysteria2ServerConfig); err != nil { + vo.Fail("content Unmarshal err", c) + return + } + + // 默认值 + config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.JwtSecret}) + if err != nil { + vo.Fail(err.Error(), c) + return + } + + var hUIWebPort string + var jwtSecret string + for _, item := range config { + if *item.Key == constant.HUIWebPort { + hUIWebPort = *item.Value + } else if *item.Key == constant.JwtSecret { + jwtSecret = *item.Value + } + } + + if hUIWebPort == "" || jwtSecret == "" { + logrus.Errorf("hUIWebPort or jwtSecret is nil") + vo.Fail(constant.SysError, c) + return + } + + authHttpUrl, err := service.GetAuthHttpUrl() + if err != nil { + vo.Fail(err.Error(), c) + return + } + + authType := "http" + authHttpInsecure := true + var auth bo.ServerConfigAuth + auth.Type = &authType + var http bo.ServerConfigAuthHTTP + http.URL = &authHttpUrl + http.Insecure = &authHttpInsecure + auth.HTTP = &http + hysteria2ServerConfig.Auth = &auth + hysteria2ServerConfig.TrafficStats.Secret = &jwtSecret + + if err = service.SetHysteria2Config(hysteria2ServerConfig); err != nil { + vo.Fail(err.Error(), c) + return + } + + running := service.Hysteria2IsRunning() + if running { + if err = service.RestartHysteria2(); err != nil { + vo.Fail(err.Error(), c) + return + } + } + + vo.Success(nil, c) +} + +func ExportConfig(c *gin.Context) { + configs, err := service.ListConfigNotIn([]string{constant.Hysteria2Config}) + if err != nil { + vo.Fail(err.Error(), c) + return + } + fileName := fmt.Sprintf("SystemConfig-%s.json", time.Now().Format("20060102150405")) + filePath := constant.ExportPathDir + fileName + + if err = util.ExportFile(filePath, configs, 0); err != nil { + vo.Fail(err.Error(), c) + return + } + + if !util.Exists(filePath) { + vo.Fail("file not exist", c) + return + } + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + c.File(filePath) +} + +func ImportConfig(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + vo.Fail(constant.SysError, c) + return + } + if header.Size > 1024*1024*2 { + vo.Fail("the file is too big", c) + return + } + if !strings.HasSuffix(header.Filename, ".json") { + vo.Fail("file format not supported", c) + return + } + content, err := io.ReadAll(file) + if err != nil { + vo.Fail("json file read err", c) + return + } + var configs []entity.Config + if err = json.Unmarshal(content, &configs); err != nil { + vo.Fail("content Unmarshal err", c) + return + } + if err = service.UpsertConfig(configs); err != nil { + vo.Fail(err.Error(), c) + return + } + go func() { + _ = service.StopServer() + }() + vo.Success(nil, c) +} + +func Hysteria2AcmePath(c *gin.Context) { + hysteria2AcmePathVo, err := service.Hysteria2AcmePath() + if err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(hysteria2AcmePathVo, c) +} + +func RestartServer(c *gin.Context) { + go func() { + _ = service.StopServer() + }() + vo.Success(nil, c) +} + +func UploadCertFile(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + vo.Fail(constant.SysError, c) + return + } + ext := filepath.Ext(file.Filename) + if ext != ".crt" && ext != ".key" { + vo.Fail("file format not supported", c) + return + } + if file.Size > 1024*1024 { + vo.Fail("the file is too big", c) + return + } + err = filepath.WalkDir(constant.BinDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + fileExt := filepath.Ext(path) + if !d.IsDir() && fileExt == ext { + if err := os.Remove(path); err != nil { + return fmt.Errorf("failed to delete file: %s, error: %v", path, err) + } + } + return nil + }) + if err != nil { + logrus.Errorf("error during file deletion: %v", err) + vo.Fail("delete file failed", c) + return + } + + wd, err := os.Getwd() + if err != nil { + vo.Fail(constant.SysError, c) + return + } + safeFilename := filepath.Base(file.Filename) + certPath := filepath.Join(wd, constant.BinDir, safeFilename) + + if err := c.SaveUploadedFile(file, certPath); err != nil { + vo.Fail("file upload failed", c) + return + } + vo.Success(certPath, c) +} diff --git a/apps/controller/hysteria2.go b/apps/controller/hysteria2.go new file mode 100644 index 0000000..06aa1c4 --- /dev/null +++ b/apps/controller/hysteria2.go @@ -0,0 +1,152 @@ +package controller + +import ( + "encoding/base64" + "github.com/gin-gonic/gin" + "github.com/skip2/go-qrcode" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/dto" + "hy2xs-admin/model/entity" + "hy2xs-admin/model/vo" + "hy2xs-admin/service" + "net/url" + "strings" + "time" +) + +func Hysteria2Auth(c *gin.Context) { + hysteria2AuthDto, err := validateField(c, dto.Hysteria2AuthDto{}) + if err != nil { + return + } + id, username, err := service.Hysteria2Auth(*hysteria2AuthDto.Auth) + if err != nil || username == "" { + vo.Hysteria2AuthFail("", c) + return + } + + // 更新最近连接时间 + now := time.Now().UnixMilli() + if err = service.UpdateAccount(entity.Account{ + BaseEntity: entity.BaseEntity{Id: &id}, + ConAt: &now, + }); err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Hysteria2AuthSuccess(username, c) +} + +func Hysteria2Kick(c *gin.Context) { + hysteria2KickDto, err := validateField(c, dto.Hysteria2KickDto{}) + if err != nil { + return + } + err = service.Hysteria2Kick(hysteria2KickDto.Ids, *hysteria2KickDto.KickUtilTime) + if err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(nil, c) +} + +func Hysteria2ChangeVersion(c *gin.Context) { + vo.Fail("Смена версии Hysteria2 отключена: runtime управляется install-оркестратором HY2XS", c) +} + +func ListRelease(c *gin.Context) { + vo.Success([]string{}, c) +} + +func Hysteria2Url(c *gin.Context) { + hysteria2UrlDto, err := validateField(c, dto.Hysteria2UrlDto{}) + if err != nil { + return + } + + url, err := service.Hysteria2Url(*hysteria2UrlDto.AccountId, *hysteria2UrlDto.Hostname) + if err != nil { + vo.Fail(err.Error(), c) + return + } + // 生成二维码 + qrCode, err := qrcode.Encode(url, qrcode.Medium, 300) + if err != nil { + vo.Fail(err.Error(), c) + return + } + hysteria2UrlVo := vo.Hysteria2UrlVo{ + Url: url, + QrCode: qrCode, + } + vo.Success(hysteria2UrlVo, c) +} + +func Hysteria2SubscribeUrl(c *gin.Context) { + hysteria2SubscribeUrlDto, err := validateField(c, dto.Hysteria2SubscribeUrlDto{}) + if err != nil { + return + } + subscribeUrl, err := service.Hysteria2SubscribeUrl(*hysteria2SubscribeUrlDto.AccountId, + *hysteria2SubscribeUrlDto.Protocol, + *hysteria2SubscribeUrlDto.Host) + if err != nil { + vo.Fail(err.Error(), c) + return + } + qrCode, err := qrcode.Encode(subscribeUrl, qrcode.Medium, 300) + if err != nil { + vo.Fail(err.Error(), c) + return + } + subscribeVo := vo.Hysteria2SubscribeVo{ + Url: subscribeUrl, + QrCode: qrCode, + } + vo.Success(subscribeVo, c) +} + +func Hysteria2Subscribe(c *gin.Context) { + conPass := c.Param("conPass") + conPass, err := url.QueryUnescape(conPass) + if err != nil { + vo.Fail("url decode err", c) + return + } + userAgent := strings.ToLower(c.Request.Header.Get("User-Agent")) + host := c.Request.Host + + if host == "" { + vo.Fail("Host is empty", c) + return + } + + var clientType string + if strings.Contains(userAgent, constant.Shadowrocket) { + clientType = constant.Shadowrocket + } else if strings.Contains(userAgent, constant.Clash) { + clientType = constant.Clash + } else if strings.Contains(userAgent, constant.V2rayN) { + clientType = constant.V2rayN + } else if strings.Contains(userAgent, constant.NekoBox) { + clientType = constant.NekoBox + } else { + clientType = constant.Clash + } + + userInfo, configStr, err := service.Hysteria2Subscribe(conPass, clientType, host) + if err != nil { + vo.Fail(err.Error(), c) + return + } + + if clientType == constant.Shadowrocket || clientType == constant.Clash { + c.Header("content-disposition", "attachment; filename=hui.yaml") + c.Header("profile-update-interval", "12") + c.Header("subscription-userinfo", userInfo) + } else if clientType == constant.V2rayN { + configStr = base64.StdEncoding.EncodeToString([]byte(configStr)) + } + + c.String(200, configStr) +} diff --git a/apps/controller/log.go b/apps/controller/log.go new file mode 100644 index 0000000..66f4585 --- /dev/null +++ b/apps/controller/log.go @@ -0,0 +1,116 @@ +package controller + +import ( + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/dto" + "hy2xs-admin/model/vo" + "hy2xs-admin/util" + "time" +) + +func LogSystem(c *gin.Context) { + logSystemDto, err := validateField(c, dto.LogDto{}) + if err != nil { + return + } + exists := util.Exists(constant.SystemLogPath) + logSystemVos := make([]vo.LogSystemVo, 0) + if !exists { + vo.Success(logSystemVos, c) + return + } + numLine := 0 + if logSystemDto.NumLine != nil || *logSystemDto.NumLine > 0 { + numLine = *logSystemDto.NumLine + } + logLines, total, err := util.ReadLinesFromBottom(constant.SystemLogPath, numLine) + if err != nil { + vo.Fail("Unable to read log file", c) + return + } + + for _, line := range logLines { + if line == "" { + continue + } + logSystemVo := vo.LogSystemVo{} + err := json.Unmarshal([]byte(line), &logSystemVo) + if err != nil { + vo.Fail("Unable to unmarshal log data", c) + continue + } + logSystemVos = append(logSystemVos, logSystemVo) + } + vo.Success(vo.LogSystemPage[vo.LogSystemVo]{ + LogSystemVos: logSystemVos, + Total: int64(total), + }, c) +} + +func LogHysteria2(c *gin.Context) { + logSystemDto, err := validateField(c, dto.LogDto{}) + if err != nil { + return + } + exists := util.Exists(constant.Hysteria2LogPath) + logHysteria2Vos := make([]vo.LogHysteria2Vo, 0) + if !exists { + vo.Success(logHysteria2Vos, c) + return + } + numLine := 0 + if logSystemDto.NumLine != nil || *logSystemDto.NumLine > 0 { + numLine = *logSystemDto.NumLine + } + logLines, total, err := util.ReadLinesFromBottom(constant.Hysteria2LogPath, numLine) + if err != nil { + vo.Fail("Unable to read log file", c) + return + } + + for _, line := range logLines { + if line == "" { + continue + } + logHysteria2Vo := vo.LogHysteria2Vo{} + err := json.Unmarshal([]byte(line), &logHysteria2Vo) + if err != nil { + vo.Fail("Unable to unmarshal log data", c) + continue + } + logHysteria2Vos = append(logHysteria2Vos, logHysteria2Vo) + } + vo.Success(vo.LogSystemPage[vo.LogHysteria2Vo]{ + LogSystemVos: logHysteria2Vos, + Total: int64(total), + }, c) +} + +func ExportLog(c *gin.Context) { + logExportDto, err := validateField(c, dto.LogExportDto{}) + if err != nil { + return + } + + var fileName string + var filePath string + if *logExportDto.Option == 0 { + fileName = fmt.Sprintf("hy2xs-admin-%s.log", time.Now().Format("20060102150405")) + filePath = constant.SystemLogPath + } else if *logExportDto.Option == 1 { + fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405")) + filePath = constant.Hysteria2LogPath + } + + if !util.Exists(filePath) { + vo.Fail("log file not exist", c) + return + } + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + c.File(filePath) +} diff --git a/apps/controller/monitor.go b/apps/controller/monitor.go new file mode 100644 index 0000000..e3e6502 --- /dev/null +++ b/apps/controller/monitor.go @@ -0,0 +1,25 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/model/vo" + "hy2xs-admin/service" +) + +func MonitorSystem(c *gin.Context) { + systemMonitorVo, err := service.MonitorSystem() + if err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(systemMonitorVo, c) +} + +func MonitorHysteria2(c *gin.Context) { + hysteria2MonitorVo, err := service.MonitorHysteria2() + if err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(hysteria2MonitorVo, c) +} diff --git a/apps/controller/validator.go b/apps/controller/validator.go new file mode 100644 index 0000000..e43cdf0 --- /dev/null +++ b/apps/controller/validator.go @@ -0,0 +1,41 @@ +package controller + +import ( + "fmt" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/vo" + "net/http" + "regexp" +) + +var validate *validator.Validate + +func init() { + validate = validator.New() + _ = validate.RegisterValidation("validateStr", validateStr) +} + +func validateStr(f validator.FieldLevel) bool { + field := f.Field().String() + // 字符串必须6-32位是字母或者数字或部分特殊字符的组合 + reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$" + compile := regexp.MustCompile(reg) + return field == "" || compile.MatchString(field) +} + +func validateField[T interface{}](c *gin.Context, field T) (T, error) { + if c.Request.Method == http.MethodGet { + _ = c.ShouldBindQuery(&field) + } else if c.Request.Method == http.MethodPost || + c.Request.Method == http.MethodPut || + c.Request.Method == http.MethodDelete { + _ = c.ShouldBindJSON(&field) + } + if err := validate.Struct(&field); err != nil { + vo.Fail(constant.InvalidError, c) + return field, fmt.Errorf(constant.InvalidError) + } + return field, nil +} diff --git a/apps/dao/account.go b/apps/dao/account.go new file mode 100644 index 0000000..37752fe --- /dev/null +++ b/apps/dao/account.go @@ -0,0 +1,119 @@ +package dao + +import ( + "errors" + "fmt" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/dto" + "hy2xs-admin/model/entity" + "time" +) + +func SaveAccount(account entity.Account) (int64, error) { + if tx := sqliteDB.Save(&account); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return 0, errors.New(constant.SysError) + } + return *account.Id, nil +} + +func DeleteAccount(ids []int64) error { + if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Account{}); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return errors.New(constant.SysError) + } + return nil +} + +func UpdateAccount(ids []int64, updates map[string]interface{}) error { + if len(updates) > 0 { + updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") + if tx := sqliteDB.Model(&entity.Account{}). + Where("id in ?", ids). + Updates(updates); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return errors.New(constant.SysError) + } + } + return nil +} + +func UpsertAccount(accounts []entity.Account) error { + if tx := sqliteDB.Model(&entity.Account{}).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "username"}}, + DoUpdates: clause.AssignmentColumns([]string{"pass", "con_pass", "quota", "download", "upload", "expire_time", "kick_util_time", "device_no", "role", "deleted", "create_time", "update_time", "login_at", "con_at", "remark"}), + }).Create(accounts); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return errors.New(constant.SysError) + } + return nil +} + +func UpdateAccountTraffic(username string, download int64, upload int64) error { + if upload != 0 || download != 0 { + updates := map[string]interface{}{} + if download != 0 { + updates["download"] = gorm.Expr("download + ?", download) + } + if upload != 0 { + updates["upload"] = gorm.Expr("upload + ?", upload) + } + updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") + if tx := sqliteDB.Model(&entity.Account{}). + Where("username = ?", username). + Updates(updates); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return errors.New(constant.SysError) + } + } + return nil +} + +func GetAccount(query interface{}, args ...interface{}) (entity.Account, error) { + var account entity.Account + if tx := sqliteDB.Model(&entity.Account{}). + Where(query, args...).First(&account); tx.Error != nil { + if tx.Error == gorm.ErrRecordNotFound { + return account, errors.New(constant.WrongPassword) + } + logrus.Errorf("%v", tx.Error) + return account, errors.New(constant.SysError) + } + return account, nil +} + +func PageAccount(accountPageDto dto.AccountPageDto) ([]entity.Account, int64, error) { + var accounts []entity.Account + var total int64 + tx := sqliteDB.Model(&entity.Account{}) + if accountPageDto.Username != nil && *accountPageDto.Username != "" { + tx.Where("username like ?", fmt.Sprintf("%%%s%%", *accountPageDto.Username)) + } + if accountPageDto.Deleted != nil { + tx.Where("deleted = ?", *accountPageDto.Deleted) + } + if accountPageDto.Remark != nil && *accountPageDto.Remark != "" { + tx.Where("remark like ?", fmt.Sprintf("%%%s%%", *accountPageDto.Remark)) + } + tx.Count(&total) + if tx.Scopes(Paginate(accountPageDto.PageNum, accountPageDto.PageSize)). + Order("role,create_time desc"). + Find(&accounts); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return accounts, 0, errors.New(constant.SysError) + } + return accounts, total, nil +} + +func ListAccount(query interface{}, args ...interface{}) ([]entity.Account, error) { + var accounts []entity.Account + if tx := sqliteDB.Model(&entity.Account{}). + Where(query, args...).Order("role,create_time desc").Find(&accounts); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return accounts, errors.New(constant.SysError) + } + return accounts, nil +} diff --git a/apps/dao/config.go b/apps/dao/config.go new file mode 100644 index 0000000..98155c6 --- /dev/null +++ b/apps/dao/config.go @@ -0,0 +1,66 @@ +package dao + +import ( + "errors" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/entity" + "time" +) + +func SaveConfig(config entity.Config) (int64, error) { + if tx := sqliteDB.Save(&config); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return 0, errors.New(constant.SysError) + } + return *config.Id, nil +} + +func UpdateConfig(keys []string, updates map[string]interface{}) error { + if len(updates) > 0 { + updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") + if tx := sqliteDB.Model(&entity.Config{}). + Where("key in ?", keys). + Updates(updates); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return errors.New(constant.SysError) + } + } + return nil +} + +func GetConfig(query interface{}, args ...interface{}) (entity.Config, error) { + var config entity.Config + if tx := sqliteDB.Model(&entity.Config{}). + Where(query, args...).First(&config); tx.Error != nil { + if tx.Error == gorm.ErrRecordNotFound { + return config, errors.New(constant.ConfigNotExist) + } + logrus.Errorf("%v", tx.Error) + return config, errors.New(constant.SysError) + } + return config, nil +} + +func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error) { + var configs []entity.Config + if tx := sqliteDB.Model(&entity.Config{}). + Where(query, args...).Order("create_time desc").Find(&configs); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return configs, errors.New(constant.SysError) + } + return configs, nil +} + +func UpsertConfig(configs []entity.Config) error { + if tx := sqliteDB.Model(&entity.Config{}).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "remark", "create_time", "update_time"}), + }).Create(configs); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return errors.New(constant.SysError) + } + return nil +} diff --git a/apps/dao/sqlite.go b/apps/dao/sqlite.go new file mode 100644 index 0000000..f9cd0eb --- /dev/null +++ b/apps/dao/sqlite.go @@ -0,0 +1,119 @@ +package dao + +import ( + "errors" + "fmt" + "github.com/glebarez/sqlite" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" + "hy2xs-admin/model/constant" + "log" + "os" + "strings" + "time" +) + +var sqlInitStr = "CREATE TABLE IF NOT EXISTS account\n(\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n username TEXT NOT NULL UNIQUE DEFAULT '',\n pass TEXT NOT NULL DEFAULT '',\n con_pass TEXT NOT NULL DEFAULT '',\n quota INTEGER NOT NULL DEFAULT 0,\n download INTEGER NOT NULL DEFAULT 0,\n upload INTEGER NOT NULL DEFAULT 0,\n expire_time INTEGER NOT NULL DEFAULT 0,\n kick_util_time INTEGER NOT NULL DEFAULT 0,\n device_no INTEGER NOT NULL DEFAULT 3,\n role TEXT NOT NULL DEFAULT 'user',\n deleted INTEGER NOT NULL DEFAULT 0,\n create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\nALTER TABLE account\n ADD COLUMN login_at INTEGER NOT NULL DEFAULT 0;\nALTER TABLE account\n ADD COLUMN con_at INTEGER NOT NULL DEFAULT 0;\nALTER TABLE account\n ADD COLUMN remark INTEGER NOT NULL DEFAULT '';\nCREATE INDEX IF NOT EXISTS account_deleted_index ON account (deleted);\nCREATE INDEX IF NOT EXISTS account_username_index ON account (username);\nCREATE INDEX IF NOT EXISTS account_con_pass_index ON account (con_pass);\nCREATE INDEX IF NOT EXISTS account_pass_index ON account (pass);\nINSERT INTO account (id, username, pass, con_pass, quota, download, upload, expire_time, device_no, role)\nSELECT 1 ,'sysadmin', '02f382b76ca1ab7aa06ab03345c7712fd5b971fb0c0f2aef98bac9cd', 'sysadmin.sysadmin', -1, 0, 0, 253370736000000, 6, 'admin'\n WHERE NOT EXISTS (SELECT 1 FROM account WHERE id = 1);\nCREATE TABLE IF NOT EXISTS config\n(\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n key TEXT NOT NULL UNIQUE DEFAULT '',\n value TEXT NOT NULL DEFAULT '',\n remark TEXT NOT NULL DEFAULT '',\n create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\nCREATE INDEX IF NOT EXISTS config_key_index ON config (key);\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_WEB_PORT', '8081', 'HY2XS admin Web Port'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_PORT');\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_WEB_CONTEXT', '/', 'HY2XS admin Web Context'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_CONTEXT');\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_CRT_PATH', '', 'HY2XS admin CRT File Path'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_CRT_PATH');\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_KEY_PATH', '', 'HY2XS admin KEY File Path'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_KEY_PATH');\nINSERT INTO config (key, value, remark)\nSELECT 'JWT_SECRET', hex(randomblob(10)), 'JWT Secret'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'JWT_SECRET');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_ENABLE', '0', 'Hysteria2 Switch'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_ENABLE');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_CONFIG', '', 'Hysteria2 Config'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_TRAFFIC_TIME', '1', 'Hysteria2 Traffic Time'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_TRAFFIC_TIME');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_CONFIG_REMARK', '', 'Hysteria2 Config Remark'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_REMARK');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_CONFIG_PORT_HOPPING', '', 'Hysteria2 Config Port Hopping'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_PORT_HOPPING');\nINSERT INTO config (key, value, remark)\nSELECT 'RESET_TRAFFIC_CRON', '', 'Reset Traffic Cron'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'RESET_TRAFFIC_CRON');\nINSERT INTO config (key, value, remark)\nSELECT 'CLASH_EXTENSION', '', 'Clash Subscription Extension'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'CLASH_EXTENSION');" + +var sqliteDB *gorm.DB + +func InitSqliteDB() error { + var err error + sqliteDB, err = gorm.Open(sqlite.Open(fmt.Sprintf("%s%s", os.Getenv("HUI_DATA"), constant.SqliteDBPath)), &gorm.Config{ + TranslateError: true, + Logger: logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: time.Second, + LogLevel: logger.Silent, + IgnoreRecordNotFoundError: true, + ParameterizedQueries: true, + Colorful: false, + }, + ), + NamingStrategy: schema.NamingStrategy{ + SingularTable: true, + }, + }) + if err != nil { + logrus.Errorf("sqlite open err: %v", err) + return errors.New("sqlite open err") + } + return nil +} + +func InitSql(port string) error { + if err := InitSqliteDB(); err != nil { + return err + } + if err := sqliteInit(sqlInitStr); err != nil { + return err + } + if port != "" { + var result string + db, err := sqliteDB.DB() + if err != nil { + return err + } + if err := db.QueryRow("SELECT value from config where key = 'H_UI_WEB_PORT' limit 1").Scan(&result); err != nil { + logrus.Errorf("sqlite exec err: %v", err) + return errors.New("sqlite exec err") + } + + if result == "8081" { + if tx := sqliteDB.Exec("UPDATE config set value = ? where key = 'H_UI_WEB_PORT'", port); tx.Error != nil { + logrus.Errorf("sqlite exec err: %v", tx.Error) + return errors.New("sqlite exec err") + } + } + } + return nil +} + +func sqliteInit(sqlStr string) error { + if sqliteDB != nil { + sqls := strings.Split(strings.Replace(sqlStr, "\r\n", "\n", -1), ";\n") + for _, s := range sqls { + s = strings.TrimSpace(s) + if s != "" { + tx := sqliteDB.Exec(s) + if tx.Error != nil && !strings.HasPrefix(tx.Error.Error(), "SQL logic error: duplicate column name") { + logrus.Errorf("sqlite exec err: %v", tx.Error) + return errors.New("sqlite exec err") + } + } + } + } + return nil +} + +func CloseSqliteDB() error { + if sqliteDB != nil { + db, err := sqliteDB.DB() + if err != nil { + logrus.Errorf("sqlite err: %v", err) + return errors.New("sqlite err") + } + if err = db.Close(); err != nil { + logrus.Errorf("sqlite close err: %v", err) + return errors.New("sqlite close err") + } + } + return nil +} + +func Paginate(pageNum *int64, pageSize *int64) func(db *gorm.DB) *gorm.DB { + var num int64 = 1 + var size int64 = 10 + if pageNum != nil && *pageNum > 0 { + num = *pageNum + } + if pageSize != nil && *pageSize > 0 { + size = *pageSize + } + return func(db *gorm.DB) *gorm.DB { + return db.Offset(int((num - 1) * size)).Limit(int(size)) + } +} diff --git a/apps/docs/images/cover.png b/apps/docs/images/cover.png new file mode 100644 index 0000000..62fc65c Binary files /dev/null and b/apps/docs/images/cover.png differ diff --git a/apps/docs/images/head-cover.png b/apps/docs/images/head-cover.png new file mode 100644 index 0000000..51bff2a Binary files /dev/null and b/apps/docs/images/head-cover.png differ diff --git a/apps/docs/sql/h_ui_db.sql b/apps/docs/sql/h_ui_db.sql new file mode 100644 index 0000000..b3dfbdb --- /dev/null +++ b/apps/docs/sql/h_ui_db.sql @@ -0,0 +1,76 @@ +CREATE TABLE IF NOT EXISTS account +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE DEFAULT '', + pass TEXT NOT NULL DEFAULT '', + con_pass TEXT NOT NULL DEFAULT '', + quota INTEGER NOT NULL DEFAULT 0, + download INTEGER NOT NULL DEFAULT 0, + upload INTEGER NOT NULL DEFAULT 0, + expire_time INTEGER NOT NULL DEFAULT 0, + kick_util_time INTEGER NOT NULL DEFAULT 0, + device_no INTEGER NOT NULL DEFAULT 3, + role TEXT NOT NULL DEFAULT 'user', + deleted INTEGER NOT NULL DEFAULT 0, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE account + ADD COLUMN login_at INTEGER NOT NULL DEFAULT 0; +ALTER TABLE account + ADD COLUMN con_at INTEGER NOT NULL DEFAULT 0; +ALTER TABLE account + ADD COLUMN remark INTEGER NOT NULL DEFAULT ''; +CREATE INDEX IF NOT EXISTS account_deleted_index ON account (deleted); +CREATE INDEX IF NOT EXISTS account_username_index ON account (username); +CREATE INDEX IF NOT EXISTS account_con_pass_index ON account (con_pass); +CREATE INDEX IF NOT EXISTS account_pass_index ON account (pass); +INSERT INTO account (id, username, pass, con_pass, quota, download, upload, expire_time, device_no, role) +SELECT 1 ,'sysadmin', '02f382b76ca1ab7aa06ab03345c7712fd5b971fb0c0f2aef98bac9cd', 'sysadmin.sysadmin', -1, 0, 0, 253370736000000, 6, 'admin' + WHERE NOT EXISTS (SELECT 1 FROM account WHERE id = 1); +CREATE TABLE IF NOT EXISTS config +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE DEFAULT '', + value TEXT NOT NULL DEFAULT '', + remark TEXT NOT NULL DEFAULT '', + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS config_key_index ON config (key); +INSERT INTO config (key, value, remark) +SELECT 'H_UI_WEB_PORT', '8081', 'HY2XS admin Web Port' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_PORT'); +INSERT INTO config (key, value, remark) +SELECT 'H_UI_WEB_CONTEXT', '/', 'HY2XS admin Web Context' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_CONTEXT'); +INSERT INTO config (key, value, remark) +SELECT 'H_UI_CRT_PATH', '', 'HY2XS admin CRT File Path' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_CRT_PATH'); +INSERT INTO config (key, value, remark) +SELECT 'H_UI_KEY_PATH', '', 'HY2XS admin KEY File Path' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_KEY_PATH'); +INSERT INTO config (key, value, remark) +SELECT 'JWT_SECRET', hex(randomblob(10)), 'JWT Secret' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'JWT_SECRET'); +INSERT INTO config (key, value, remark) +SELECT 'HYSTERIA2_ENABLE', '0', 'Hysteria2 Switch' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_ENABLE'); +INSERT INTO config (key, value, remark) +SELECT 'HYSTERIA2_CONFIG', '', 'Hysteria2 Config' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG'); +INSERT INTO config (key, value, remark) +SELECT 'HYSTERIA2_TRAFFIC_TIME', '1', 'Hysteria2 Traffic Time' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_TRAFFIC_TIME'); +INSERT INTO config (key, value, remark) +SELECT 'HYSTERIA2_CONFIG_REMARK', '', 'Hysteria2 Config Remark' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_REMARK'); +INSERT INTO config (key, value, remark) +SELECT 'HYSTERIA2_CONFIG_PORT_HOPPING', '', 'Hysteria2 Config Port Hopping' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_PORT_HOPPING'); +INSERT INTO config (key, value, remark) +SELECT 'RESET_TRAFFIC_CRON', '', 'Reset Traffic Cron' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'RESET_TRAFFIC_CRON'); +INSERT INTO config (key, value, remark) +SELECT 'CLASH_EXTENSION', '', 'Clash Subscription Extension' + WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'CLASH_EXTENSION'); diff --git a/apps/frontend/.editorconfig b/apps/frontend/.editorconfig new file mode 100644 index 0000000..dd76572 --- /dev/null +++ b/apps/frontend/.editorconfig @@ -0,0 +1,14 @@ +# http://editorconfig.org +root = true + +# 表示所有文件适用 +[*] +charset = utf-8 # 设置文件字符集为 utf-8 +end_of_line = lf # 控制换行类型(lf | cr | crlf) +indent_style = tab # 缩进风格(tab | space) +insert_final_newline = true # 始终在文件末尾插入一个新行 + +# 表示仅 md 文件适用以下规则 +[*.md] +max_line_length = off # 关闭最大行长度限制 +trim_trailing_whitespace = false # 关闭末尾空格修剪 diff --git a/apps/frontend/.env.development b/apps/frontend/.env.development new file mode 100644 index 0000000..af15405 --- /dev/null +++ b/apps/frontend/.env.development @@ -0,0 +1,8 @@ +## 开发环境 + +# 变量必须以 VITE_ 为前缀才能暴露给外部读取 +NODE_ENV='development' + +VITE_APP_TITLE = 'h-ui' +VITE_APP_PORT = 3000 +VITE_APP_BASE_API = '/hui' diff --git a/apps/frontend/.env.production b/apps/frontend/.env.production new file mode 100644 index 0000000..40e7f4a --- /dev/null +++ b/apps/frontend/.env.production @@ -0,0 +1,5 @@ +## 生产环境 + +VITE_APP_TITLE = 'h-ui' +VITE_APP_PORT = 80 +VITE_APP_BASE_API = '/hui' diff --git a/apps/frontend/.eslintignore b/apps/frontend/.eslintignore new file mode 100644 index 0000000..68aacde --- /dev/null +++ b/apps/frontend/.eslintignore @@ -0,0 +1,13 @@ +dist +node_modules +public +.vscode +.idea +*.sh +*.md + +src/assets + +.eslintrc.cjs +.prettierrc.cjs +.stylelintrc.cjs diff --git a/apps/frontend/.eslintrc-auto-import.json b/apps/frontend/.eslintrc-auto-import.json new file mode 100644 index 0000000..3620c18 --- /dev/null +++ b/apps/frontend/.eslintrc-auto-import.json @@ -0,0 +1,269 @@ +{ + "globals": { + "EffectScope": true, + "ElForm": true, + "ElMessage": true, + "ElMessageBox": true, + "ElTree": true, + "asyncComputed": true, + "autoResetRef": true, + "computed": true, + "computedAsync": true, + "computedEager": true, + "computedInject": true, + "computedWithControl": true, + "controlledComputed": true, + "controlledRef": true, + "createApp": true, + "createEventHook": true, + "createGlobalState": true, + "createInjectionState": true, + "createReactiveFn": true, + "createSharedComposable": true, + "createUnrefFn": true, + "customRef": true, + "debouncedRef": true, + "debouncedWatch": true, + "defineAsyncComponent": true, + "defineComponent": true, + "eagerComputed": true, + "effectScope": true, + "extendRef": true, + "getCurrentInstance": true, + "getCurrentScope": true, + "h": true, + "ignorableWatch": true, + "inject": true, + "isDefined": true, + "isProxy": true, + "isReactive": true, + "isReadonly": true, + "isRef": true, + "makeDestructurable": true, + "markRaw": true, + "nextTick": true, + "onActivated": true, + "onBeforeMount": true, + "onBeforeUnmount": true, + "onBeforeUpdate": true, + "onClickOutside": true, + "onDeactivated": true, + "onErrorCaptured": true, + "onKeyStroke": true, + "onLongPress": true, + "onMounted": true, + "onRenderTracked": true, + "onRenderTriggered": true, + "onScopeDispose": true, + "onServerPrefetch": true, + "onStartTyping": true, + "onUnmounted": true, + "onUpdated": true, + "pausableWatch": true, + "provide": true, + "reactify": true, + "reactifyObject": true, + "reactive": true, + "reactiveComputed": true, + "reactiveOmit": true, + "reactivePick": true, + "readonly": true, + "ref": true, + "refAutoReset": true, + "refDebounced": true, + "refDefault": true, + "refThrottled": true, + "refWithControl": true, + "resolveComponent": true, + "resolveDirective": true, + "resolveRef": true, + "resolveUnref": true, + "shallowReactive": true, + "shallowReadonly": true, + "shallowRef": true, + "syncRef": true, + "syncRefs": true, + "templateRef": true, + "throttledRef": true, + "throttledWatch": true, + "toRaw": true, + "toReactive": true, + "toRef": true, + "toRefs": true, + "triggerRef": true, + "tryOnBeforeMount": true, + "tryOnBeforeUnmount": true, + "tryOnMounted": true, + "tryOnScopeDispose": true, + "tryOnUnmounted": true, + "unref": true, + "unrefElement": true, + "until": true, + "useActiveElement": true, + "useArrayEvery": true, + "useArrayFilter": true, + "useArrayFind": true, + "useArrayFindIndex": true, + "useArrayFindLast": true, + "useArrayJoin": true, + "useArrayMap": true, + "useArrayReduce": true, + "useArraySome": true, + "useArrayUnique": true, + "useAsyncQueue": true, + "useAsyncState": true, + "useAttrs": true, + "useBase64": true, + "useBattery": true, + "useBluetooth": true, + "useBreakpoints": true, + "useBroadcastChannel": true, + "useBrowserLocation": true, + "useCached": true, + "useClipboard": true, + "useCloned": true, + "useColorMode": true, + "useConfirmDialog": true, + "useCounter": true, + "useCssModule": true, + "useCssVar": true, + "useCssVars": true, + "useCurrentElement": true, + "useCycleList": true, + "useDark": true, + "useDateFormat": true, + "useDebounce": true, + "useDebounceFn": true, + "useDebouncedRefHistory": true, + "useDeviceMotion": true, + "useDeviceOrientation": true, + "useDevicePixelRatio": true, + "useDevicesList": true, + "useDisplayMedia": true, + "useDocumentVisibility": true, + "useDraggable": true, + "useDropZone": true, + "useElementBounding": true, + "useElementByPoint": true, + "useElementHover": true, + "useElementSize": true, + "useElementVisibility": true, + "useEventBus": true, + "useEventListener": true, + "useEventSource": true, + "useEyeDropper": true, + "useFavicon": true, + "useFetch": true, + "useFileDialog": true, + "useFileSystemAccess": true, + "useFocus": true, + "useFocusWithin": true, + "useFps": true, + "useFullscreen": true, + "useGamepad": true, + "useGeolocation": true, + "useIdle": true, + "useImage": true, + "useInfiniteScroll": true, + "useIntersectionObserver": true, + "useInterval": true, + "useIntervalFn": true, + "useKeyModifier": true, + "useLastChanged": true, + "useLocalStorage": true, + "useMagicKeys": true, + "useManualRefHistory": true, + "useMediaControls": true, + "useMediaQuery": true, + "useMemoize": true, + "useMemory": true, + "useMounted": true, + "useMouse": true, + "useMouseInElement": true, + "useMousePressed": true, + "useMutationObserver": true, + "useNavigatorLanguage": true, + "useNetwork": true, + "useNow": true, + "useObjectUrl": true, + "useOffsetPagination": true, + "useOnline": true, + "usePageLeave": true, + "useParallax": true, + "usePermission": true, + "usePointer": true, + "usePointerLock": true, + "usePointerSwipe": true, + "usePreferredColorScheme": true, + "usePreferredContrast": true, + "usePreferredDark": true, + "usePreferredLanguages": true, + "usePreferredReducedMotion": true, + "usePrevious": true, + "useRafFn": true, + "useRefHistory": true, + "useResizeObserver": true, + "useScreenOrientation": true, + "useScreenSafeArea": true, + "useScriptTag": true, + "useScroll": true, + "useScrollLock": true, + "useSessionStorage": true, + "useShare": true, + "useSlots": true, + "useSorted": true, + "useSpeechRecognition": true, + "useSpeechSynthesis": true, + "useStepper": true, + "useStorage": true, + "useStorageAsync": true, + "useStyleTag": true, + "useSupported": true, + "useSwipe": true, + "useTemplateRefsList": true, + "useTextDirection": true, + "useTextSelection": true, + "useTextareaAutosize": true, + "useThrottle": true, + "useThrottleFn": true, + "useThrottledRefHistory": true, + "useTimeAgo": true, + "useTimeout": true, + "useTimeoutFn": true, + "useTimeoutPoll": true, + "useTimestamp": true, + "useTitle": true, + "useToNumber": true, + "useToString": true, + "useToggle": true, + "useTransition": true, + "useUrlSearchParams": true, + "useUserMedia": true, + "useVModel": true, + "useVModels": true, + "useVibrate": true, + "useVirtualList": true, + "useWakeLock": true, + "useWebNotification": true, + "useWebSocket": true, + "useWebWorker": true, + "useWebWorkerFn": true, + "useWindowFocus": true, + "useWindowScroll": true, + "useWindowSize": true, + "watch": true, + "watchArray": true, + "watchAtMost": true, + "watchDebounced": true, + "watchEffect": true, + "watchIgnorable": true, + "watchOnce": true, + "watchPausable": true, + "watchPostEffect": true, + "watchSyncEffect": true, + "watchThrottled": true, + "watchTriggerable": true, + "watchWithFilter": true, + "whenever": true + } +} diff --git a/apps/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs new file mode 100644 index 0000000..a2af090 --- /dev/null +++ b/apps/frontend/.eslintrc.cjs @@ -0,0 +1,32 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true, + }, + parser: "vue-eslint-parser", // https://eslint.vuejs.org/user-guide/#bundle-configurations + extends: [ + "eslint:recommended", + "plugin:vue/vue3-essential", + "plugin:@typescript-eslint/recommended", + "./.eslintrc-auto-import.json", + ], + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + parser: "@typescript-eslint/parser", + }, + plugins: ["vue", "@typescript-eslint"], + rules: { + "vue/multi-word-component-names": "off", // 关闭组件名必须多字: https://eslint.vuejs.org/rules/multi-word-component-names.html + "@typescript-eslint/no-empty-function": "off", // 关闭空方法检查 + "@typescript-eslint/no-explicit-any": "off", // 关闭any类型的警告 + "vue/no-v-model-argument": "off", + "@typescript-eslint/no-non-null-assertion": "off", + }, // https://eslint.org/docs/latest/use/configure/language-options#specifying-globals + globals: { + DialogType: "readonly", + OptionType: "readonly", + }, +}; +цц \ No newline at end of file diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore new file mode 100644 index 0000000..7f60325 --- /dev/null +++ b/apps/frontend/.gitignore @@ -0,0 +1,14 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.local diff --git a/apps/frontend/.prettierignore b/apps/frontend/.prettierignore new file mode 100644 index 0000000..3a05d3f --- /dev/null +++ b/apps/frontend/.prettierignore @@ -0,0 +1,9 @@ +dist +node_modules +public +.vscode +.idea +*.sh +*.md + +src/assets diff --git a/apps/frontend/.prettierrc.cjs b/apps/frontend/.prettierrc.cjs new file mode 100644 index 0000000..d39f28c --- /dev/null +++ b/apps/frontend/.prettierrc.cjs @@ -0,0 +1,36 @@ +module.exports = { + // (x)=>{},单个参数箭头函数是否显示小括号。(always:始终显示;avoid:省略括号。默认:always) + arrowParens: "always", + // 开始标签的右尖括号是否跟随在最后一行属性末尾,默认false + bracketSameLine: false, + // 对象字面量的括号之间打印空格 (true - Example: { foo: bar } ; false - Example: {foo:bar}) + bracketSpacing: true, + // 是否格式化一些文件中被嵌入的代码片段的风格(auto|off;默认auto) + embeddedLanguageFormatting: "auto", + // 指定 HTML 文件的空格敏感度 (css|strict|ignore;默认css) + htmlWhitespaceSensitivity: "css", + // 当文件已经被 Prettier 格式化之后,是否会在文件顶部插入一个特殊的 @format 标记,默认false + insertPragma: false, + // 在 JSX 中使用单引号替代双引号,默认false + jsxSingleQuote: false, + // 每行最多字符数量,超出换行(默认80) + printWidth: 80, + // 超出打印宽度 (always | never | preserve ) + proseWrap: "preserve", + // 对象属性是否使用引号(as-needed | consistent | preserve;默认as-needed:对象的属性需要加引号才添加;) + quoteProps: "as-needed", + // 是否只格式化在文件顶部包含特定注释(@prettier| @format)的文件,默认false + requirePragma: false, + // 结尾添加分号 + semi: true, + // 使用单引号 (true:单引号;false:双引号) + singleQuote: false, + // 缩进空格数,默认2个空格 + tabWidth: 2, + // 元素末尾是否加逗号,默认es5: ES5中的 objects, arrays 等会添加逗号,TypeScript 中的 type 后不加逗号 + trailingComma: "es5", + // 指定缩进方式,空格或tab,默认false,即使用空格 + useTabs: false, + // vue 文件中是否缩进 \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/quota.svg b/apps/frontend/src/assets/icons/quota.svg new file mode 100644 index 0000000..f897b12 --- /dev/null +++ b/apps/frontend/src/assets/icons/quota.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/refresh.svg b/apps/frontend/src/assets/icons/refresh.svg new file mode 100644 index 0000000..1f549f1 --- /dev/null +++ b/apps/frontend/src/assets/icons/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/report.svg b/apps/frontend/src/assets/icons/report.svg new file mode 100644 index 0000000..bb90070 --- /dev/null +++ b/apps/frontend/src/assets/icons/report.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/setting.svg b/apps/frontend/src/assets/icons/setting.svg new file mode 100644 index 0000000..0f1962f --- /dev/null +++ b/apps/frontend/src/assets/icons/setting.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/share.svg b/apps/frontend/src/assets/icons/share.svg new file mode 100644 index 0000000..31e5b5a --- /dev/null +++ b/apps/frontend/src/assets/icons/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/size.svg b/apps/frontend/src/assets/icons/size.svg new file mode 100644 index 0000000..ddb25b8 --- /dev/null +++ b/apps/frontend/src/assets/icons/size.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/upload.svg b/apps/frontend/src/assets/icons/upload.svg new file mode 100644 index 0000000..e6e2b6b --- /dev/null +++ b/apps/frontend/src/assets/icons/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/user.svg b/apps/frontend/src/assets/icons/user.svg new file mode 100644 index 0000000..bcb2394 --- /dev/null +++ b/apps/frontend/src/assets/icons/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/users.svg b/apps/frontend/src/assets/icons/users.svg new file mode 100644 index 0000000..ab86674 --- /dev/null +++ b/apps/frontend/src/assets/icons/users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/logo.png b/apps/frontend/src/assets/logo.png new file mode 100644 index 0000000..51bff2a Binary files /dev/null and b/apps/frontend/src/assets/logo.png differ diff --git a/apps/frontend/src/components/Hamburger/index.vue b/apps/frontend/src/components/Hamburger/index.vue new file mode 100644 index 0000000..374a1b5 --- /dev/null +++ b/apps/frontend/src/components/Hamburger/index.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/apps/frontend/src/components/ImputMultiple/index.vue b/apps/frontend/src/components/ImputMultiple/index.vue new file mode 100644 index 0000000..76376de --- /dev/null +++ b/apps/frontend/src/components/ImputMultiple/index.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/apps/frontend/src/components/LangSelect/index.vue b/apps/frontend/src/components/LangSelect/index.vue new file mode 100644 index 0000000..7ad680c --- /dev/null +++ b/apps/frontend/src/components/LangSelect/index.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/frontend/src/components/MapAdd/index.vue b/apps/frontend/src/components/MapAdd/index.vue new file mode 100644 index 0000000..bd68c67 --- /dev/null +++ b/apps/frontend/src/components/MapAdd/index.vue @@ -0,0 +1,180 @@ + + + + + + + diff --git a/apps/frontend/src/components/Pagination/index.vue b/apps/frontend/src/components/Pagination/index.vue new file mode 100644 index 0000000..b81f55d --- /dev/null +++ b/apps/frontend/src/components/Pagination/index.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/apps/frontend/src/components/RightPanel/index.vue b/apps/frontend/src/components/RightPanel/index.vue new file mode 100644 index 0000000..08f5966 --- /dev/null +++ b/apps/frontend/src/components/RightPanel/index.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/apps/frontend/src/components/SizeSelect/index.vue b/apps/frontend/src/components/SizeSelect/index.vue new file mode 100644 index 0000000..f0800ba --- /dev/null +++ b/apps/frontend/src/components/SizeSelect/index.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/frontend/src/components/SvgIcon/index.vue b/apps/frontend/src/components/SvgIcon/index.vue new file mode 100644 index 0000000..e9d145e --- /dev/null +++ b/apps/frontend/src/components/SvgIcon/index.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/apps/frontend/src/components/UnitSelect/index.vue b/apps/frontend/src/components/UnitSelect/index.vue new file mode 100644 index 0000000..db4ae90 --- /dev/null +++ b/apps/frontend/src/components/UnitSelect/index.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/apps/frontend/src/directive/index.ts b/apps/frontend/src/directive/index.ts new file mode 100644 index 0000000..eec32b0 --- /dev/null +++ b/apps/frontend/src/directive/index.ts @@ -0,0 +1,9 @@ +import type { App } from "vue"; + +import { hasRole } from "./permission"; + +// 全局注册 directive +export function setupDirective(app: App) { + // 使 v-hasRole 在所有组件中都可用 + app.directive("hasRole", hasRole); +} diff --git a/apps/frontend/src/directive/permission/index.ts b/apps/frontend/src/directive/permission/index.ts new file mode 100644 index 0000000..4e42434 --- /dev/null +++ b/apps/frontend/src/directive/permission/index.ts @@ -0,0 +1,25 @@ +import { useAccountStoreHook } from "@/store/modules/account"; +import { Directive, DirectiveBinding } from "vue"; + +/** + * 角色权限 + */ +export const hasRole: Directive = { + mounted(el: HTMLElement, binding: DirectiveBinding) { + const { value } = binding; + + if (value) { + const requiredRoles = value; // DOM绑定需要的角色编码 + const { roles } = useAccountStoreHook(); + const hasRole = roles.some((perm) => { + return requiredRoles.includes(perm); + }); + + if (!hasRole) { + el.parentNode && el.parentNode.removeChild(el); + } + } else { + throw new Error("need roles! Like v-has-role=\"['admin', 'user']\""); + } + }, +}; diff --git a/apps/frontend/src/lang/index.ts b/apps/frontend/src/lang/index.ts new file mode 100644 index 0000000..63ac05a --- /dev/null +++ b/apps/frontend/src/lang/index.ts @@ -0,0 +1,24 @@ +import { createI18n } from "vue-i18n"; +import { useAppStore } from "@/store/modules/app"; + +const appStore = useAppStore(); +import enLocale from "./package/en"; +import ruLocale from "./package/ru"; + +const messages = { + ru: { + ...ruLocale, + }, + en: { + ...enLocale, + }, +}; + +const i18n = createI18n({ + legacy: false, + locale: appStore.language, + messages: messages, + globalInjection: true, +}); + +export default i18n; diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts new file mode 100644 index 0000000..1dacc22 --- /dev/null +++ b/apps/frontend/src/lang/package/en.ts @@ -0,0 +1,324 @@ +export default { + // 路由国际化 + route: { + account: "Account", + accountList: "Account Manage", + hysteria: "Hysteria", + hysteriaList: "Hysteria Manage", + config: "System", + configList: "System Config", + monitor: "Monitor", + monitorSystem: "System Monitor", + log: "Log", + logSystem: "System Log", + logHysteria: "Hysteria Log", + info: "Info", + infoAccount: "Account Info", + }, + // 登录页面国际化 + login: { + title: "HY2XS admin", + username: "Username", + password: "Password", + login: "Login", + }, + // 导航栏国际化 + navbar: { + logout: "Logout", + }, + common: { + id: "ID", + createTime: "Create Time", + operate: "Operate", + edit: "Edit", + delete: "Delete", + deleted: "Status", + all: "All", + enable: "Enable", + disable: "Disable", + search: "Search", + reset: "Reset", + add: "Add", + confirm: "Confirm", + cancel: "Cancel", + copySuccess: "Copy successful", + subscribe: "Subscribe", + subscribeQrCode: "Subscribe QR Code", + nodeUrl: "Node URL", + nodeQrCode: "Node QR Code", + resetTraffic: "Reset traffic", + import: "Import", + export: "Export", + save: "Save", + update: "Update", + downloadSuccess: "Download successful", + wait: "The version is being changed, please wait a moment", + enableSuccess: "Hysteria2 start successful", + disableSuccess: "Hysteria2 stop successful", + success: "Success", + refresh: "Refresh", + yes: "Yes", + no: "No", + securityRisk: "Security Risks", + defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. Click here to change`, + noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. Click here to enable`, + }, + info: { + expireTime: "y-M-d H:m:s", + greeting1: "The cool and fresh air awakens your energy for the day🌅!", + greeting2: "Good morning,", + greeting3: "Good afternoon,", + greeting4: "Good evening,", + greeting5: + "I want to be a shooting star, cutting through the darkness, just to illuminate your dreams, good night🌛!", + }, + account: { + remark: "Remark", + username: "Username", + pass: "Pass", + conPass: "ConPass", + quota: "Quota", + download: "Download", + upload: "Upload", + expireTime: "Expire Time", + kickUtilTimeLast: "Offline Remaining Time", + kickUtilTime: "Offline Time", + deviceNo: "Limit Devices", + onlineStatus: "Online Status", + online: "Online", + offline: "Offline", + device: "Online Devices", + role: "Role", + unit: "Unit", + loginAt: "Last login time", + conAt: "Last connection time", + createTime: "Create Time", + releaseSuccess: "Release successful", + kick: "Kick", + kickTip: "Force user to log off", + releaseKick: "Release", + releaseKickTip: "Remove offline status", + }, + config: { + huiWebPort: "HY2XS admin Web Port", + huiWebContext: "HY2XS admin Web Context", + hysteria2TrafficTime: "Hysteria2 Traffic Time", + huiCrtPath: "HY2XS admin CRT File Path", + huiKeyPath: "HY2XS admin KEY File Path", + uploadCrtFile: "Upload CRT File", + uploadKeyFile: "Upload KEY File", + restartServer: "Restart Panel", + restartTip: "Restarting, please refresh", + useHysteria2Cert: "Use Hysteria2 cert", + huiHttps: "Open https on the panel", + resetTrafficCron: "Reset traffic schedule task", + resetTrafficCronTip: + "Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3", + resetTrafficMonth: "Run once a month, midnight, first of month", + resetTrafficWeek: "Run once a week, midnight between Sat/Sun", + }, + monitor: { + huiVersion: "HY2XS admin Version", + cpuPercent: "CPU Usage", + memPercent: "Memory Usage", + diskPercent: "Disk Usage", + hysteria2UserTotal: "Number of online users", + hysteria2DeviceTotal: "Number of online devices", + hysteria2Version: "Hysteria2 Version", + hysteria2Running: "Hysteria2 Status", + hysteria2RunningTrue: "Running", + hysteria2RunningFalse: "Stop", + }, + log: { + numLine: "Number of lines", + }, + hysteria: { + enable: "Enable", + disable: "Disable", + addConfigItem: "Add Config Item", + hysteria2Version: "Hysteria2 Version", + hysteria2Running: "Hysteria2 Status", + hysteria2ChangeVersion: "Change", + addOutbound: "Add Outbound", + extension: "Extension", + listen: "Listen", + tls: "TLS", + obfs: "Obfuscation", + quic: "QUIC parameters", + bandwidth: "Bandwidth", + speedTest: "Speed Test", + udp: "UDP", + resolver: "Resolver", + sniff: "Protocol Sniffing", + acl: "ACL", + outbounds: "Outbounds", + http: "Traffic Stats API (HTTP)", + masquerade: "Masquerade", + config: { + enable: "Enable/Disable", + remark: "Remark", + portHopping: + "Port Hopping, Multiple individual ports: 1234,5678,9012; A range of ports: 20000-50000; A combination of both: 1234,5000-6000,7044,8000-9000", + clashExtension: "Clash subscription extension", + listen: + "When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.", + tlsType: "TLS type", + tls: { + cert: "The path to the Cert file.", + key: "The path to the Key file.", + sniGuard: + 'Verify the SNI provided by the client. Accept the connection only when it matches what\'s in the certificate. Terminate the TLS handshake otherwise. Set to strict to enforce this behavior. Set to disable to disable this entirely. The default is dns-san, which enables this feature only when the certificate contains the "Subject Alternative Name" extension with a domain name in it.', + }, + acme: { + domains: "Domains", + email: "Email", + ca: "The CA to use. Can be letsencrypt or zerossl.", + listenHost: + "The host address (not including the port) to listen on for the ACME challenge. If omitted, the server will listen on all interfaces.", + dir: "The directory to store the ACME account key and certificates.", + type: "ACME challenge type. Can be http, tls, or dns.", + http: { + altPort: + "Listening port for HTTP challenges. (Note: Changing to a port other than 80 requires port forwarding or HTTP reverse proxy, or the challenge will fail!)", + }, + tls: { + altPort: + "Listening port for TLS-ALPN challenges. (Note: Changing to a port other than 443 requires port forwarding or TLS reverse proxy, or the challenge will fail!)", + }, + dns: { + name: "DNS provider. For details, refer to ACME DNS Configuration.", + config: "ACME DNS Configuration", + }, + disableHTTP: "Disable HTTP challenge.", + disableTLSALPN: "Disable TLS-ALPN challenge.", + altHTTPPort: + "Alternate HTTP challenge port. (Note: If you want to use anything other than 80, you must set up port forward/HTTP reverse proxy from 80 to that port, otherwise ACME will not be able to issue the certificate.)", + altTLSALPNPort: + "Alternate TLS-ALPN challenge port. (Note: If you want to use anything other than 443, you must set up port forward/SNI proxy from 443 to that port, otherwise ACME will not be able to issue the certificate.)", + }, + obfs: { + type: "Type", + salamander: { + password: "Replace with a strong password of your choice.", + }, + }, + quic: { + initStreamReceiveWindow: "The initial QUIC stream receive window size.", + maxStreamReceiveWindow: "The maximum QUIC stream receive window size.", + initConnReceiveWindow: + "The initial QUIC connection receive window size.", + maxConnReceiveWindow: + "The maximum QUIC connection receive window size.", + maxIdleTimeout: + "The maximum idle timeout. How long the server will consider the client still connected without any activity.", + maxIncomingStreams: + "The maximum number of concurrent incoming streams.", + disablePathMTUDiscovery: "Disable QUIC path MTU discovery.", + }, + bandwidth: { + up: "Up", + down: "Down", + }, + ignoreClientBandwidth: + "When enabled, makes the server to disregard any bandwidth hints set by clients", + speedTest: + "speedTest enables the built-in speed test server. When enabled, clients can test their download and upload speeds with the server. For more information, see the Speed Test documentation.", + disableUDP: + "disableUDP disables UDP forwarding, only allowing TCP connections.", + udpIdleTimeout: + "udpIdleTimeout specifies the amount of time the server will keep a local UDP port open for each UDP session that has no activity. This is conceptually similar to the NAT UDP session timeout.", + resolver: { + type: "Type", + tcp: { + addr: "The address of the TCP resolver.", + timeout: "The timeout for DNS queries.", + }, + udp: { + addr: "The address of the UDP resolver.", + timeout: "The timeout for DNS queries.", + }, + tls: { + addr: "The address of the TLS resolver.", + timeout: "The timeout for DNS queries.", + sni: "The SNI to use for the TLS resolver.", + insecure: "Disable TLS verification for the TLS resolver.", + }, + https: { + addr: "The address of the HTTPS resolver.", + timeout: "The timeout for DNS queries.", + sni: "The SNI to use for the TLS resolver.", + insecure: "Disable TLS verification for the TLS resolver.", + }, + }, + sniff: { + enable: "Whether to enable protocol sniffing.", + timeout: + "Sniffing timeout. If the protocol/domain cannot be determined within this time, the original address will be used to initiate the connection.", + rewriteDomain: + "Whether to rewrite requests that are already in domain name form. If enabled, requests with the target address already in domain name form will still be sniffed.", + tcpPorts: + "List of TCP ports. Only TCP requests on these ports will be sniffed.", + udpPorts: + "List of UDP ports. Only UDP requests on these ports will be sniffed.", + }, + aclType: "ACL type", + acl: { + file: "The path to the ACL file.", + inline: "The list of inline ACL rules.", + geoip: + "Optional. Uncomment to enable. The path to the GeoIP database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.", + geosite: + "Optional. Uncomment to enable. The path to the GeoSite database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.", + geoUpdateInterval: + "Optional. The interval at which to refresh the GeoIP/GeoSite databases. 168 hours (1 week) by default. Only applies if the GeoIP/GeoSite databases are automatically downloaded. (Check the note below for more information.)", + }, + outbounds: { + name: "The name of the outbound. This is used in ACL rules.", + type: "Type", + socks5: { + addr: "The address of the SOCKS5 proxy.", + username: + "Optional. The username for the SOCKS5 proxy, if authentication is required.", + password: + "Optional. The password for the SOCKS5 proxy, if authentication is required.", + }, + http: { + url: "The URL of the HTTP/HTTPS proxy. (Can be http:// or https://)", + insecure: + "Optional. Whether to disable TLS verification. Applies to HTTPS proxies only.", + }, + direct: { + mode: "Type", + bindIPv4: "The local IPv4 address to bind to.", + bindIPv6: "The local IPv6 address to bind to.", + bindDevice: "The local network interface to bind to.", + fastOpen: "Enable TCP fast open.", + }, + }, + trafficStats: { + listen: "The address to listen on.", + }, + masquerade: { + type: "Type", + file: { + dir: "The directory to serve files from.", + }, + proxy: { + url: "The URL of the website to proxy.", + rewriteHost: + "Whether to rewrite the Host header to match the proxied website. This is required if the target web server uses Host to determine which site to serve.", + insecure: "Disable TLS verification for the proxied website.", + }, + string: { + content: "The string to return.", + headers: "Optional. The headers to return.", + statusCode: "Optional. The status code to return. 200 by default.", + }, + listenHTTP: "HTTP (TCP) listen address.", + listenHTTPS: "HTTPS (TCP) listen address.", + forceHTTPS: + "Whether to force HTTPS. If enabled, all HTTP requests will be redirected to HTTPS.", + }, + }, + }, +}; diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts new file mode 100644 index 0000000..6cf7c4b --- /dev/null +++ b/apps/frontend/src/lang/package/ru.ts @@ -0,0 +1,258 @@ +export default { + route: { + account: "Аккаунты", + accountList: "Управление аккаунтами", + hysteria: "Hysteria", + hysteriaList: "Управление Hysteria", + config: "Система", + configList: "Настройки системы", + monitor: "Мониторинг", + monitorSystem: "Системный мониторинг", + log: "Логи", + logSystem: "Системные логи", + logHysteria: "Логи Hysteria", + info: "Информация", + infoAccount: "Профиль", + }, + login: { + title: "HY2XS admin", + username: "Логин", + password: "Пароль", + login: "Войти", + }, + navbar: { + logout: "Выйти", + }, + common: { + id: "ID", + createTime: "Создано", + operate: "Действия", + edit: "Изменить", + delete: "Удалить", + deleted: "Статус", + all: "Все", + enable: "Включено", + disable: "Отключено", + search: "Поиск", + reset: "Сброс", + add: "Добавить", + confirm: "Подтвердить", + cancel: "Отмена", + copySuccess: "Скопировано", + subscribe: "Ссылка подписки", + subscribeQrCode: "QR подписки", + nodeUrl: "URL узла", + nodeQrCode: "QR узла", + resetTraffic: "Сбросить трафик", + import: "Импорт", + export: "Экспорт", + save: "Сохранить", + update: "Обновить", + downloadSuccess: "Загрузка завершена", + wait: "Версия меняется, подождите", + enableSuccess: "Hysteria2 запущена", + disableSuccess: "Hysteria2 остановлена", + success: "Готово", + refresh: "Обновить", + yes: "Да", + no: "Нет", + securityRisk: "Риски безопасности", + defaultPassTip: `Смените пароль по умолчанию как можно скорее. Перейти к смене`, + noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. Открыть настройки`, + }, + info: { + expireTime: "г-М-д Ч:м:с", + greeting1: "Доброе утро,", + greeting2: "Доброе утро,", + greeting3: "Добрый день,", + greeting4: "Добрый вечер,", + greeting5: "Доброй ночи,", + }, + account: { + remark: "Комментарий", + username: "Логин", + pass: "Пароль входа", + conPass: "Пароль подключения", + quota: "Квота", + download: "Скачано", + upload: "Отдано", + expireTime: "Срок действия", + kickUtilTimeLast: "Осталось офлайн", + kickUtilTime: "Отключить до", + deviceNo: "Лимит устройств", + onlineStatus: "Онлайн", + online: "Онлайн", + offline: "Офлайн", + device: "Устройства", + role: "Роль", + unit: "Ед. изм.", + loginAt: "Последний вход", + conAt: "Последнее подключение", + createTime: "Создано", + releaseSuccess: "Ограничение снято", + kick: "Отключить", + kickTip: "Принудительно отключить пользователя", + releaseKick: "Снять", + releaseKickTip: "Снять офлайн-статус", + }, + config: { + huiWebPort: "Порт HY2XS admin", + huiWebContext: "Web-контекст HY2XS admin", + hysteria2TrafficTime: "Период учёта трафика Hysteria2", + huiCrtPath: "Путь к CRT HY2XS admin", + huiKeyPath: "Путь к KEY HY2XS admin", + uploadCrtFile: "Загрузить CRT", + uploadKeyFile: "Загрузить KEY", + restartServer: "Перезапустить панель", + restartTip: "Перезапуск, обновите страницу", + useHysteria2Cert: "Использовать сертификат Hysteria2", + huiHttps: "Включить HTTPS панели", + resetTrafficCron: "Расписание сброса трафика", + resetTrafficCronTip: "Cron-выражение для планового сброса трафика", + resetTrafficMonth: "Раз в месяц, в полночь первого дня", + resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем", + }, + monitor: { + huiVersion: "Версия HY2XS admin", + cpuPercent: "CPU", + memPercent: "Память", + diskPercent: "Диск", + hysteria2UserTotal: "Пользователей онлайн", + hysteria2DeviceTotal: "Устройств онлайн", + hysteria2Version: "Версия Hysteria2", + hysteria2Running: "Статус Hysteria2", + hysteria2RunningTrue: "Работает", + hysteria2RunningFalse: "Остановлена", + }, + log: { + numLine: "Количество строк", + }, + hysteria: { + enable: "Включить", + disable: "Отключить", + addConfigItem: "Добавить параметр", + hysteria2Version: "Версия Hysteria2", + hysteria2Running: "Статус Hysteria2", + hysteria2ChangeVersion: "Сменить", + addOutbound: "Добавить outbound", + extension: "Расширение", + listen: "Адрес прослушивания", + tls: "TLS", + obfs: "Маскировка", + quic: "Параметры QUIC", + bandwidth: "Полоса", + speedTest: "Тест скорости", + udp: "UDP", + resolver: "DNS", + sniff: "Sniffing протоколов", + acl: "ACL", + outbounds: "Outbounds", + http: "Traffic Stats API (HTTP)", + masquerade: "Masquerade", + config: { + enable: "Включить/отключить", + remark: "Комментарий", + portHopping: "Port hopping: отдельные порты, диапазоны или их комбинации", + clashExtension: "Расширение подписки Clash", + listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.", + tlsType: "Тип TLS", + tls: { + cert: "Путь к cert-файлу", + key: "Путь к key-файлу", + sniGuard: "Проверка SNI клиента перед принятием TLS-соединения.", + }, + acme: { + domains: "Домены", + email: "Email", + ca: "CA: letsencrypt или zerossl", + listenHost: "Адрес для ACME challenge", + dir: "Каталог ACME аккаунта и сертификатов", + type: "Тип ACME challenge: http, tls или dns", + http: { altPort: "Альтернативный порт HTTP challenge" }, + tls: { altPort: "Альтернативный порт TLS-ALPN challenge" }, + dns: { name: "DNS-провайдер", config: "Конфигурация ACME DNS" }, + disableHTTP: "Отключить HTTP challenge", + disableTLSALPN: "Отключить TLS-ALPN challenge", + altHTTPPort: "Альтернативный HTTP-порт", + altTLSALPNPort: "Альтернативный TLS-ALPN-порт", + }, + obfs: { + type: "Тип", + salamander: { password: "Сильный пароль Salamander" }, + }, + quic: { + initStreamReceiveWindow: "Начальное окно приёма QUIC stream", + maxStreamReceiveWindow: "Максимальное окно приёма QUIC stream", + initConnReceiveWindow: "Начальное окно приёма QUIC connection", + maxConnReceiveWindow: "Максимальное окно приёма QUIC connection", + maxIdleTimeout: "Максимальный idle timeout", + maxIncomingStreams: "Максимум входящих stream", + disablePathMTUDiscovery: "Отключить QUIC path MTU discovery", + }, + bandwidth: { up: "Вверх", down: "Вниз" }, + ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом", + speedTest: "Встроенный сервер теста скорости", + disableUDP: "Отключить UDP forwarding", + udpIdleTimeout: "Idle timeout для UDP-сессий", + resolver: { + type: "Тип", + tcp: { addr: "TCP DNS resolver", timeout: "Timeout DNS-запросов" }, + udp: { addr: "UDP DNS resolver", timeout: "Timeout DNS-запросов" }, + tls: { + addr: "DNS over TLS resolver", + timeout: "Timeout DNS-запросов", + sni: "SNI для TLS resolver", + insecure: "Отключить TLS-проверку", + }, + https: { + addr: "DNS over HTTPS resolver", + timeout: "Timeout DNS-запросов", + sni: "SNI для HTTPS resolver", + insecure: "Отключить TLS-проверку", + }, + }, + sniff: { + enable: "Включить sniffing", + timeout: "Timeout sniffing", + rewriteDomain: "Повторно анализировать доменные запросы", + tcpPorts: "TCP-порты для sniffing", + udpPorts: "UDP-порты для sniffing", + }, + aclType: "Тип ACL", + acl: { + file: "Путь к ACL-файлу", + inline: "Inline ACL-правила", + geoip: "Путь к GeoIP базе", + geosite: "Путь к GeoSite базе", + geoUpdateInterval: "Интервал обновления GeoIP/GeoSite", + }, + outbounds: { + name: "Имя outbound", + type: "Тип", + socks5: { addr: "Адрес SOCKS5", username: "Логин SOCKS5", password: "Пароль SOCKS5" }, + http: { url: "URL HTTP/HTTPS proxy", insecure: "Отключить TLS-проверку proxy" }, + direct: { + mode: "Тип", + bindIPv4: "Локальный IPv4", + bindIPv6: "Локальный IPv6", + bindDevice: "Сетевой интерфейс", + fastOpen: "TCP fast open", + }, + }, + trafficStats: { listen: "Адрес прослушивания" }, + masquerade: { + type: "Тип", + file: { dir: "Каталог файлов" }, + proxy: { + url: "URL проксируемого сайта", + rewriteHost: "Переписывать Host header", + insecure: "Отключить TLS-проверку", + }, + string: { content: "Ответ строкой", headers: "HTTP headers", statusCode: "HTTP status code" }, + listenHTTP: "HTTP listen address", + listenHTTPS: "HTTPS listen address", + forceHTTPS: "Принудительно использовать HTTPS", + }, + }, + }, +}; diff --git a/apps/frontend/src/lang/package/zh-cn.ts b/apps/frontend/src/lang/package/zh-cn.ts new file mode 100644 index 0000000..d69e24a --- /dev/null +++ b/apps/frontend/src/lang/package/zh-cn.ts @@ -0,0 +1,312 @@ +export default { + // 路由国际化 + route: { + account: "账户", + accountList: "账户管理", + hysteria: "Hysteria", + hysteriaList: "Hysteria 管理", + config: "系统", + configList: "系统设置", + monitor: "监控", + monitorSystem: "系统监控", + log: "日志", + logSystem: "系统日志", + logHysteria: "Hysteria 日志", + info: "信息", + infoAccount: "账户信息", + }, + // 登录页面国际化 + login: { + title: "HY2XS admin", + username: "用户名", + password: "密码", + login: "登 录", + }, + // 导航栏国际化 + navbar: { + logout: "注销", + }, + common: { + id: "编号", + createTime: "创建时间", + operate: "操作", + edit: "编辑", + delete: "删除", + deleted: "状态", + all: "全部", + enable: "正常", + disable: "禁用", + search: "搜索", + reset: "重设", + add: "新增", + confirm: "确定", + cancel: "取消", + copySuccess: "复制成功", + subscribe: "订阅链接", + subscribeQrCode: "订阅二维码", + nodeUrl: "节点 URL", + nodeQrCode: "节点二维码", + resetTraffic: "重设流量", + import: "导入", + export: "导出", + save: "保存", + update: "更新", + downloadSuccess: "下载成功", + wait: "正在更换版本,请等待一会儿", + enableSuccess: "Hysteria2 启动 !!", + disableSuccess: "Hysteria2 已关闭", + success: "成功", + refresh: "刷新", + yes: "是", + no: "否", + securityRisk: `安全隐患`, + defaultPassTip: `请尽快修改默认登录密码,建议设置强密码以保护您的账户安全。点击这里修改`, + noHttpsTip: `您的网站未启用 HTTPS,数据传输不安全,请尽快开启 HTTPS 以保护用户信息。点击这里开启`, + }, + info: { + expireTime: "年-月-日 时:分:秒", + greeting1: "微凉扑面,清新的空气,唤醒一天的活力🌅!", + greeting2: "上午好,", + greeting3: "下午好,", + greeting4: "晚上好,", + greeting5: "我愿成为流星,划破黑夜,只为照亮你的梦境,晚安🌛!", + }, + account: { + remark: "备注", + username: "用户名", + pass: "登录密码", + conPass: "连接密码", + quota: "配额", + download: "下载", + upload: "上传", + expireTime: "过期时间", + kickUtilTimeLast: "下线剩余时间", + kickUtilTime: "下线截止时间", + deviceNo: "限制设备数", + onlineStatus: "在线状态", + online: "在线", + offline: "离线", + device: "在线设备数", + role: "角色", + unit: "单位", + loginAt: "最近登录时间", + conAt: "最近连接时间", + createTime: "注册时间", + releaseSuccess: "解除成功", + kick: "下线", + kickTip: "强制用户下线", + releaseKick: "解除", + releaseKickTip: "解除下线状态", + }, + config: { + huiWebPort: "HY2XS admin Web 端口", + huiWebContext: "HY2XS admin Web 上下文", + hysteria2TrafficTime: "Hysteria2 流量倍数", + huiCrtPath: "HY2XS admin CRT 证书路径", + huiKeyPath: "HY2XS admin KEY 证书路径", + uploadCrtFile: "上传 CRT 证书", + uploadKeyFile: "上传 KEY 证书", + restartServer: "重启面板", + restartTip: "正在重启,请刷新", + useHysteria2Cert: "使用 Hysteria2 的证书", + huiHttps: "面板开启 https", + resetTrafficCron: "重设流量计划任务", + resetTrafficCronTip: + "计划任务表达式,参考:https://pkg.go.dev/github.com/robfig/cron/v3", + resetTrafficMonth: "每月运行一次,每月第一天午夜", + resetTrafficWeek: "每周运行一次,周六/周日午夜", + }, + monitor: { + huiVersion: "HY2XS admin 版本", + cpuPercent: "CPU 使用率", + memPercent: "内存使用率", + diskPercent: "磁盘使用率", + hysteria2UserTotal: "在线用户数", + hysteria2DeviceTotal: "在线设备数", + hysteria2Version: "Hysteria2 版本", + hysteria2Running: "Hysteria2 状态", + hysteria2RunningTrue: "运行", + hysteria2RunningFalse: "停止", + }, + log: { + numLine: "显示行数", + }, + hysteria: { + enable: "开启", + disable: "关闭", + addConfigItem: "添加配置项", + hysteria2Version: "Hysteria2 版本", + hysteria2Running: "Hysteria2 状态", + hysteria2ChangeVersion: "切换", + addOutbound: "添加出站规则", + extension: "扩展", + listen: "监听地址", + tls: "TLS", + obfs: "混淆", + quic: "QUIC 参数", + bandwidth: "带宽", + speedTest: "速度测试", + udp: "UDP", + resolver: "DNS 解析", + sniff: "协议嗅探 (Sniff)", + acl: "ACL", + outbounds: "出站规则", + http: "流量统计 API", + masquerade: "伪装", + config: { + enable: "开启/关闭", + remark: "别名", + portHopping: + "端口跳跃,多个单端口:1234,5678,9012;端口范围:20000-50000;两者的组合:1234,5000-6000,7044,8000-9000", + clashExtension: "Clash 订阅扩展", + listen: + "当只有端口没有 IP 地址时,服务器将监听所有可用的 IPv4 和 IPv6 地址。要仅监听 IPv4,可以使用 0.0.0.0:443。要仅监听 IPv6,可以使用 [::]:443。", + tlsType: "TLS 类型", + tls: { + cert: "CERT 路径", + key: "KEY 路径", + sniGuard: + "验证客户端发送的 SNI。 与证书信息匹配时才建立连接, 否则终止 TLS 握手。 设置为 strict 以启用该功能。 设置为 disable 以禁用该功能。 默认为 dns-san, 仅当证书中包含「证书主题背景的备用名称」扩展且该扩展中包含域名时才启用该功能。", + }, + acme: { + domains: "域名", + email: "邮箱", + ca: "要使用的 CA。可以是 letsencrypt 或 zerossl。", + listenHost: + "用于 ACME 服务器验证的监听地址(不含端口)。默认监听所有可用的地址。", + dir: "存储 ACME 账户密钥和证书的目录。", + type: "ACME 验证类型。可以是 http, tls 或 dns。", + http: { + altPort: + "用于 HTTP 挑战的监听端口。 (注意: 改为非 80 需要另行配置端口转发或者 HTTP 反向代理,否则证书会签署失败!)", + }, + tls: { + altPort: + "用于 TLS-ALPN 挑战的监听端口。 (注意: 改为非 443 需要另行配置端口转发或者 SNI Proxy,否则证书会签署失败!)", + }, + dns: { + name: "DNS 提供商。详细信息请参考 ACME DNS 配置。", + config: "ACME DNS 配置", + }, + disableHTTP: "禁用 HTTP 挑战。", + disableTLSALPN: "禁用 TLS-ALPN 挑战。", + altHTTPPort: + "用于 HTTP 挑战的监听端口。 (注意: 改为非 80 需要另行配置端口转发或者 HTTP 反向代理,否则证书会签署失败!)", + altTLSALPNPort: + "用于 TLS-ALPN 挑战的监听端口。 (注意: 改为非 443 需要另行配置端口转发或者 SNI Proxy,否则证书会签署失败!)", + }, + obfs: { + type: "类型", + salamander: { + password: "替换为你的混淆密码。", + }, + }, + quic: { + initStreamReceiveWindow: "初始的 QUIC 流接收窗口大小。", + maxStreamReceiveWindow: "最大的 QUIC 流接收窗口大小。", + initConnReceiveWindow: "初始的 QUIC 连接接收窗口大小。", + maxConnReceiveWindow: "最大的 QUIC 连接接收窗口大小。", + maxIdleTimeout: + "最长空闲超时时间。服务器会在多长时间没有收到任何客户端数据后关闭连接。", + maxIncomingStreams: "最大并发传入流的数量。", + disablePathMTUDiscovery: "禁用 MTU 探测。", + }, + bandwidth: { + up: "上传", + down: "下载", + }, + ignoreClientBandwidth: "忽略客户端带宽设置", + speedTest: "speedTest 启用后,服务端将允许客户端进行下载和上传速度测试。", + disableUDP: "disableUDP 启用后服务端禁用 UDP 转发,只支持 TCP。", + udpIdleTimeout: + "udpIdleTimeout 用于指定服务器对于每个 UDP 会话,在没有流量时保持本地 UDP 端口的时间长度。概念上与 NAT 的 UDP 会话超时时间相似。", + resolver: { + type: "类型", + tcp: { + addr: "TCP DNS 服务器地址。", + timeout: "DNS 查询超时时间。", + }, + udp: { + addr: "UDP DNS 服务器地址。", + timeout: "DNS 查询超时时间。", + }, + tls: { + addr: "DNS over TLS 服务器地址。", + timeout: "DNS 查询超时时间。", + sni: "DNS over TLS 服务器的 SNI。", + insecure: "禁用 TLS 证书验证。", + }, + https: { + addr: "DNS over HTTPS 服务器地址。", + timeout: "DNS 查询超时时间。", + sni: "DNS over TLS 服务器的 SNI。", + insecure: "禁用 TLS 证书验证。", + }, + }, + sniff: { + enable: "是否启用协议嗅探。", + timeout: + "嗅探超时时间。如果超过这个时间仍然无法确定协议/获取域名,将使用原地址发起连接。", + rewriteDomain: + "是否重写已经是域名的请求。如果启用,对于目标地址已经是域名的请求,仍会进行嗅探。", + tcpPorts: "TCP 端口列表。只有这些端口的 TCP 请求会被嗅探。", + udpPorts: "UDP 端口列表。只有这些端口的 UDP 请求会被嗅探。", + }, + aclType: "ACL 类型", + acl: { + file: "ACL 文件的路径。", + inline: "内联 ACL 规则的列表。", + geoip: + "可选。取消注释以启用。GeoIP 数据库文件的路径。如果省略这个字段,Hysteria 会自动下载最新的数据库到工作目录。", + geosite: + "可选。取消注释以启用。GeoSite 数据库文件的路径。如果省略这个字段,Hysteria 会自动下载最新的数据库到工作目录。", + geoUpdateInterval: + "可选。GeoIP/GeoSite 数据库刷新的间隔。默认为 168 小时(1 周)。仅在 GeoIP/GeoSite 数据库是自动下载的情况下生效。", + }, + outbounds: { + name: "出站规则的名称。在 ACL 中使用。", + type: "类型", + socks5: { + addr: "SOCKS5 代理地址。", + username: "可选。SOCKS5 代理用户名。", + password: "可选。SOCKS5 代理密码。", + }, + http: { + url: "HTTP/HTTPS 代理 URL。(可以是 http:// 或 https:// 开头)", + insecure: "可选。禁用 TLS 证书验证。仅适用于 HTTPS 代理。", + }, + direct: { + mode: "类型", + bindIPv4: "要绑定的本地 IPv4 地址。", + bindIPv6: "要绑定的本地 IPv6 地址。", + bindDevice: "要绑定的本地网卡。", + fastOpen: "启用 TCP 快速打开。", + }, + }, + trafficStats: { + listen: "监听地址。", + }, + masquerade: { + type: "类型", + file: { + dir: "用于提供文件的目录。", + }, + proxy: { + url: "要代理的网站的 URL。", + rewriteHost: + "是否重写 Host 头以匹配被代理的网站。如果目标网站通过 Host 识别请求的网站,这个选项是必须的。", + insecure: "禁用对代理网站的 TLS 验证。", + }, + string: { + content: "要返回的字符串。", + headers: "可选。要返回的 HTTP 头列表。", + statusCode: "可选。要返回的 HTTP 状态码。默认为 200。", + }, + listenHTTP: "HTTP (TCP) 监听地址。", + listenHTTPS: "HTTPS (TCP) 监听地址。", + forceHTTPS: + "是否强制使用 HTTPS。如果启用,HTTP 请求将被重定向到 HTTPS。", + }, + }, + }, +}; diff --git a/apps/frontend/src/layout/components/AppMain.vue b/apps/frontend/src/layout/components/AppMain.vue new file mode 100644 index 0000000..0de8378 --- /dev/null +++ b/apps/frontend/src/layout/components/AppMain.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/apps/frontend/src/layout/components/Navbar.vue b/apps/frontend/src/layout/components/Navbar.vue new file mode 100644 index 0000000..8a2cd61 --- /dev/null +++ b/apps/frontend/src/layout/components/Navbar.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/apps/frontend/src/layout/components/Settings/index.vue b/apps/frontend/src/layout/components/Settings/index.vue new file mode 100644 index 0000000..d426198 --- /dev/null +++ b/apps/frontend/src/layout/components/Settings/index.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/apps/frontend/src/layout/components/Sidebar/Link.vue b/apps/frontend/src/layout/components/Sidebar/Link.vue new file mode 100644 index 0000000..d80b289 --- /dev/null +++ b/apps/frontend/src/layout/components/Sidebar/Link.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/frontend/src/layout/components/Sidebar/Logo.vue b/apps/frontend/src/layout/components/Sidebar/Logo.vue new file mode 100644 index 0000000..ca4ed53 --- /dev/null +++ b/apps/frontend/src/layout/components/Sidebar/Logo.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/apps/frontend/src/layout/components/Sidebar/SidebarItem.vue b/apps/frontend/src/layout/components/Sidebar/SidebarItem.vue new file mode 100644 index 0000000..06ebf71 --- /dev/null +++ b/apps/frontend/src/layout/components/Sidebar/SidebarItem.vue @@ -0,0 +1,121 @@ + + diff --git a/apps/frontend/src/layout/components/Sidebar/index.vue b/apps/frontend/src/layout/components/Sidebar/index.vue new file mode 100644 index 0000000..86b807b --- /dev/null +++ b/apps/frontend/src/layout/components/Sidebar/index.vue @@ -0,0 +1,45 @@ + + + diff --git a/apps/frontend/src/layout/components/TagsView/ScrollPane.vue b/apps/frontend/src/layout/components/TagsView/ScrollPane.vue new file mode 100644 index 0000000..f2b7e7a --- /dev/null +++ b/apps/frontend/src/layout/components/TagsView/ScrollPane.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/apps/frontend/src/layout/components/TagsView/index.vue b/apps/frontend/src/layout/components/TagsView/index.vue new file mode 100644 index 0000000..ee7c5ef --- /dev/null +++ b/apps/frontend/src/layout/components/TagsView/index.vue @@ -0,0 +1,373 @@ + + + + + diff --git a/apps/frontend/src/layout/components/index.ts b/apps/frontend/src/layout/components/index.ts new file mode 100644 index 0000000..6e86a8f --- /dev/null +++ b/apps/frontend/src/layout/components/index.ts @@ -0,0 +1,4 @@ +export { default as Navbar } from "./Navbar.vue"; +export { default as AppMain } from "./AppMain.vue"; +export { default as Settings } from "./Settings/index.vue"; +export { default as TagsView } from "./TagsView/index.vue"; diff --git a/apps/frontend/src/layout/index.vue b/apps/frontend/src/layout/index.vue new file mode 100644 index 0000000..dda75b3 --- /dev/null +++ b/apps/frontend/src/layout/index.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/apps/frontend/src/main.ts b/apps/frontend/src/main.ts new file mode 100644 index 0000000..3b57f7c --- /dev/null +++ b/apps/frontend/src/main.ts @@ -0,0 +1,26 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import router from "@/router"; +import { setupStore } from "@/store"; +import { setupDirective } from "@/directive"; + +import "@/permission"; + +// 本地SVG图标 +import "virtual:svg-icons-register"; + +// 国际化 +import i18n from "@/lang/index"; + +// 样式 +import "element-plus/theme-chalk/dark/css-vars.css"; +import "@/styles/index.scss"; +import "uno.css"; + +const app = createApp(App); +// 全局注册 自定义指令(directive) +setupDirective(app); +// 全局注册 状态管理(store) +setupStore(app); + +app.use(router).use(i18n).mount("#app"); diff --git a/apps/frontend/src/permission.ts b/apps/frontend/src/permission.ts new file mode 100644 index 0000000..a5f0d60 --- /dev/null +++ b/apps/frontend/src/permission.ts @@ -0,0 +1,62 @@ +import router from "@/router"; +import { useAccountStoreHook } from "@/store/modules/account"; +import { usePermissionStoreHook } from "@/store/modules/permission"; + +import NProgress from "nprogress"; +import "nprogress/nprogress.css"; + +NProgress.configure({ showSpinner: false }); // 进度条 + +const permissionStore = usePermissionStoreHook(); + +// 白名单路由 +const whiteList = ["/login", "/register"]; + +router.beforeEach(async (to, from, next) => { + NProgress.start(); + const hasToken = localStorage.getItem("accessToken"); + if (hasToken) { + if (to.path === "/login") { + // 如果已登录,跳转首页 + next({ path: "/" }); + NProgress.done(); + } else { + const AccountStore = useAccountStoreHook(); + const hasRoles = AccountStore.roles && AccountStore.roles.length > 0; + if (hasRoles) { + // 未匹配到任何路由,跳转404 + if (to.matched.length === 0) { + from.name ? next({ name: from.name }) : next("/404"); + } else { + next(); + } + } else { + try { + const { roles } = await AccountStore.getAccountInfo(); + const accessRoutes = permissionStore.generateRoutes(roles); + accessRoutes.forEach((route) => { + router.addRoute(route); + }); + next({ ...to, replace: true }); + } catch (error) { + // 移除 token 并跳转登录页 + await AccountStore.resetToken(); + next(`/login?redirect=${to.path}`); + NProgress.done(); + } + } + } + } else { + // 未登录可以访问白名单页面 + if (whiteList.indexOf(to.path) !== -1) { + next(); + } else { + next(`/login?redirect=${to.path}`); + NProgress.done(); + } + } +}); + +router.afterEach(() => { + NProgress.done(); +}); diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts new file mode 100644 index 0000000..82c246a --- /dev/null +++ b/apps/frontend/src/router/index.ts @@ -0,0 +1,203 @@ +import { + createRouter, + createWebHashHistory, + RouteLocationNormalized, + RouteRecordRaw, +} from "vue-router"; + +export const Layout = () => import("@/layout/index.vue"); + +// 静态路由 +export const constantRoutes: RouteRecordRaw[] = [ + { + path: "/redirect", + component: Layout, + meta: { hidden: true }, + children: [ + { + path: "/redirect/:path(.*)", + component: () => import("@/views/redirect/index.vue"), + }, + ], + }, + + { + path: "/login", + component: () => import("@/views/login/index.vue"), + meta: { hidden: true }, + }, + { + path: "/", + component: Layout, + redirect: "/info/account", + children: [ + { + path: "401", + component: () => import("@/views/error-page/401.vue"), + meta: { hidden: true }, + }, + { + path: "404", + component: () => import("@/views/error-page/404.vue"), + meta: { hidden: true }, + }, + ], + }, +]; + +export const asyncRoutes: any[] = [ + { + path: "/info", + component: "Layout", + redirect: "/account", + name: "Info", + meta: { + title: "info", + icon: "user", + roles: ["user", "admin"], + }, + children: [ + { + path: "account", + component: "info/account/index", + name: "AccountInfo", + meta: { + title: "infoAccount", + icon: "user", + roles: ["user", "admin"], + }, + }, + ], + }, + { + path: "/account", + component: "Layout", + redirect: "/list", + name: "Account", + meta: { title: "account", icon: "users", roles: ["admin"] }, + children: [ + { + path: "list", + component: "account/list/index", + name: "AccountList", + meta: { + title: "accountList", + icon: "users", + roles: ["admin"], + }, + props: (route: RouteLocationNormalized) => ({ + focus: route.query.focus, + }), + }, + ], + }, + { + path: "/hysteria", + component: "Layout", + redirect: "/list", + name: "Hysteria", + meta: { title: "hysteria", icon: "hysteria", roles: ["admin"] }, + children: [ + { + path: "list", + component: "hysteria/list/index", + name: "HysteriaList", + meta: { + title: "hysteriaList", + icon: "hysteria", + roles: ["admin"], + }, + }, + ], + }, + { + path: "/config", + component: "Layout", + redirect: "/list", + name: "Config", + meta: { title: "config", icon: "setting", roles: ["admin"] }, + children: [ + { + path: "list", + component: "config/list/index", + name: "ConfigList", + meta: { + title: "configList", + icon: "setting", + roles: ["admin"], + }, + props: (route: RouteLocationNormalized) => ({ + focus: route.query.focus, + }), + }, + ], + }, + { + path: "/monitor", + component: "Layout", + redirect: "/monitor", + name: "Monitor", + meta: { title: "monitor", icon: "report", roles: ["admin"] }, + children: [ + { + path: "system", + component: "monitor/system/index", + name: "MonitorSystem", + meta: { + title: "monitorSystem", + icon: "report", + roles: ["admin"], + }, + }, + ], + }, + { + path: "/log", + component: "Layout", + redirect: "/system", + name: "Log", + meta: { title: "log", icon: "error", roles: ["admin"] }, + children: [ + { + path: "system", + component: "log/system/index", + name: "LogSystem", + meta: { + title: "logSystem", + icon: "log-system", + roles: ["admin"], + }, + }, + { + path: "hysteria", + component: "log/hysteria/index", + name: "LogHysteria", + meta: { + title: "logHysteria", + icon: "log-hysteria", + roles: ["admin"], + }, + }, + ], + }, +]; + +/** + * 创建路由 + */ +const router = createRouter({ + history: createWebHashHistory(), + routes: constantRoutes as RouteRecordRaw[], + // 刷新时,滚动条位置还原 + scrollBehavior: () => ({ left: 0, top: 0 }), +}); + +/** + * 重置路由 + */ +export function resetRouter() { + router.replace({ path: "/login" }); + location.reload(); +} + +export default router; diff --git a/apps/frontend/src/settings.ts b/apps/frontend/src/settings.ts new file mode 100644 index 0000000..58e7da0 --- /dev/null +++ b/apps/frontend/src/settings.ts @@ -0,0 +1,67 @@ +// 系统设置 +interface DefaultSettings { + /** + * 系统title + */ + title: string; + + /** + * 是否显示设置 + */ + showSettings: boolean; + /** + * 是否显示多标签导航 + */ + tagsView: boolean; + /** + *是否固定头部 + */ + fixedHeader: boolean; + /** + * 是否显示侧边栏Logo + */ + sidebarLogo: boolean; + /** + * 导航栏布局 + */ + layout: string; + /** + * 主题颜色 + */ + themeColor: string; + /** + * 主题模式 + */ + theme: string; + + /** + * 布局大小 + */ + size: string; + + /** + * 语言 + */ + language: string; +} + +const defaultSettings: DefaultSettings = { + title: "HY2XS admin", + showSettings: true, + tagsView: true, + fixedHeader: false, + sidebarLogo: true, + layout: "left", + themeColor: "#409EFF", + /** + * 主题模式 + * + * dark:暗黑模式 + * light: 明亮模式 + */ + theme: "dark", + size: "default", // default |large |small + language: "ru", // ru | en +}; + +export default defaultSettings; diff --git a/apps/frontend/src/store/index.ts b/apps/frontend/src/store/index.ts new file mode 100644 index 0000000..e22b67c --- /dev/null +++ b/apps/frontend/src/store/index.ts @@ -0,0 +1,11 @@ +import type { App } from "vue"; +import { createPinia } from "pinia"; + +const store = createPinia(); + +// 全局注册 store +export function setupStore(app: App) { + app.use(store); +} + +export { store }; diff --git a/apps/frontend/src/store/modules/account.ts b/apps/frontend/src/store/modules/account.ts new file mode 100644 index 0000000..f9b6e4d --- /dev/null +++ b/apps/frontend/src/store/modules/account.ts @@ -0,0 +1,91 @@ +import { defineStore } from "pinia"; + +import { getAccountInfoApi, loginApi } from "@/api/account"; +import { resetRouter } from "@/router"; +import { store } from "@/store"; + +import { AccountInfo, AccountLoginDto } from "@/api/account/types"; + +import { useStorage } from "@vueuse/core"; + +export const useAccountStore = defineStore("account", () => { + // state + const token = useStorage("accessToken", ""); + const id = ref(0); + const username = ref(""); + const roles = ref>([]); // 用户角色编码集合 → 判断路由权限 + + /** + * 登录 + * + * @returns + */ + function login(accountLoginDto: AccountLoginDto) { + return new Promise((resolve, reject) => { + loginApi(accountLoginDto) + .then((response) => { + const { tokenType, accessToken } = response.data; + token.value = tokenType + " " + accessToken; // Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); + } + + // 查询当前 + function getAccountInfo() { + return new Promise((resolve, reject) => { + getAccountInfoApi() + .then(({ data }) => { + if (!data) { + return reject("Verification failed, please Login again."); + } + if (!data.roles || data.roles.length <= 0) { + reject("getAccountInfoApi: roles must be a non-null array!"); + } + id.value = data.id; + username.value = data.username; + roles.value = data.roles; + resolve(data); + }) + .catch((error) => { + reject(error); + }); + }); + } + + // 注销 + function logout() { + return new Promise((resolve, reject) => { + resetRouter(); + resetToken(); + resolve(); + }); + } + + // 重置 + function resetToken() { + token.value = ""; + id.value = 0; + username.value = ""; + roles.value = []; + } + + return { + token, + id, + username, + roles, + login, + getAccountInfo, + logout, + resetToken, + }; +}); + +// 非setup +export function useAccountStoreHook() { + return useAccountStore(store); +} diff --git a/apps/frontend/src/store/modules/app.ts b/apps/frontend/src/store/modules/app.ts new file mode 100644 index 0000000..d128c10 --- /dev/null +++ b/apps/frontend/src/store/modules/app.ts @@ -0,0 +1,80 @@ +import { defineStore } from "pinia"; +import { useStorage } from "@vueuse/core"; +import defaultSettings from "@/settings"; + +import en from "element-plus/es/locale/lang/en"; +import ru from "element-plus/es/locale/lang/ru"; + +// setup +export const useAppStore = defineStore("app", () => { + // state + const device = useStorage("device", "desktop"); + const size = useStorage("size", defaultSettings.size); + const language = useStorage("language", defaultSettings.language); + + const sidebarStatus = useStorage("sidebarStatus", "closed"); + const sidebar = reactive({ + opened: sidebarStatus.value !== "closed", + withoutAnimation: false, + }); + + /** + * 根据语言标识读取对应的语言包 + */ + const locale = computed(() => { + return language?.value == "en" ? en : ru; + }); + + // actions + function toggleSidebar(withoutAnimation: boolean) { + sidebar.opened = !sidebar.opened; + sidebar.withoutAnimation = withoutAnimation; + if (sidebar.opened) { + sidebarStatus.value = "opened"; + } else { + sidebarStatus.value = "closed"; + } + } + + function closeSideBar(withoutAnimation: boolean) { + sidebar.opened = false; + sidebar.withoutAnimation = withoutAnimation; + sidebarStatus.value = "closed"; + } + + function openSideBar(withoutAnimation: boolean) { + sidebar.opened = true; + sidebar.withoutAnimation = withoutAnimation; + sidebarStatus.value = "opened"; + } + + function toggleDevice(val: string) { + device.value = val; + } + + function changeSize(val: string) { + size.value = val; + } + /** + * 切换语言 + * + * @param val + */ + function changeLanguage(val: string) { + language.value = val; + } + + return { + device, + sidebar, + language, + locale, + size, + toggleDevice, + changeSize, + changeLanguage, + toggleSidebar, + closeSideBar, + openSideBar, + }; +}); diff --git a/apps/frontend/src/store/modules/permission.ts b/apps/frontend/src/store/modules/permission.ts new file mode 100644 index 0000000..49a177d --- /dev/null +++ b/apps/frontend/src/store/modules/permission.ts @@ -0,0 +1,97 @@ +import { RouteRecordRaw } from "vue-router"; +import { defineStore } from "pinia"; +import { asyncRoutes, constantRoutes } from "@/router"; +import { store } from "@/store"; + +const modules = import.meta.glob("../../views/**/**.vue"); +const Layout = () => import("@/layout/index.vue"); + +/** + * Use meta.role to determine if the current user has permission + * + * @param roles 用户角色集合 + * @param route 路由 + * @returns + */ +const hasPermission = (roles: string[], route: RouteRecordRaw) => { + if (route.meta && route.meta.roles) { + // 角色【超级管理员】拥有所有权限,忽略校验 + if (roles.includes("admin")) { + return true; + } + return roles.some((role) => { + if (route.meta?.roles !== undefined) { + return (route.meta.roles as string[]).includes(role); + } + }); + } + return false; +}; + +/** + * 递归过滤有权限的异步(动态)路由 + * + * @param routes 接口返回的异步(动态)路由 + * @param roles 用户角色集合 + * @returns 返回用户有权限的异步(动态)路由 + */ +const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]) => { + const asyncRoutes: RouteRecordRaw[] = []; + + routes.forEach((route) => { + const tmpRoute = { ...route }; // ES6扩展运算符复制新对象 + + // 判断用户(角色)是否有该路由的访问权限 + if (hasPermission(roles, tmpRoute)) { + if (tmpRoute.component?.toString() == "Layout") { + tmpRoute.component = Layout; + } else { + const component = modules[`../../views/${tmpRoute.component}.vue`]; + if (component) { + tmpRoute.component = component; + } else { + tmpRoute.component = modules[`../../views/error-page/404.vue`]; + } + } + + if (tmpRoute.children) { + tmpRoute.children = filterAsyncRoutes(tmpRoute.children, roles); + } + + asyncRoutes.push(tmpRoute); + } + }); + + return asyncRoutes; +}; + +// setup +export const usePermissionStore = defineStore("permission", () => { + // state + const routes = ref([]); + + // actions + function setRoutes(newRoutes: RouteRecordRaw[]) { + routes.value = constantRoutes.concat(newRoutes); + } + + /** + * 生成动态路由 + * + * @param roles 用户角色集合 + * @returns + */ + function generateRoutes(roles: string[]) { + // 根据角色获取有访问权限的路由 + const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles); + setRoutes(accessedRoutes); + return accessedRoutes; + } + + return { routes, setRoutes, generateRoutes }; +}); + +// 非setup +export function usePermissionStoreHook() { + return usePermissionStore(store); +} diff --git a/apps/frontend/src/store/modules/settings.ts b/apps/frontend/src/store/modules/settings.ts new file mode 100644 index 0000000..c9c8451 --- /dev/null +++ b/apps/frontend/src/store/modules/settings.ts @@ -0,0 +1,56 @@ +import { defineStore } from "pinia"; +import defaultSettings from "@/settings"; +import { useStorage } from "@vueuse/core"; + +export const useSettingsStore = defineStore("setting", () => { + // state + const tagsView = useStorage("tagsView", defaultSettings.tagsView); + + const showSettings = ref(defaultSettings.showSettings); + const fixedHeader = ref(defaultSettings.fixedHeader); + const sidebarLogo = ref(defaultSettings.sidebarLogo); + + const layout = useStorage("layout", defaultSettings.layout); + + const themeColor = useStorage( + "themeColor", + defaultSettings.themeColor + ); + + // actions + function changeSetting(param: { key: string; value: any }) { + const { key, value } = param; + switch (key) { + case "showSettings": + showSettings.value = value; + break; + case "fixedHeader": + fixedHeader.value = value; + break; + case "tagsView": + tagsView.value = value; + break; + case "sidevarLogo": + sidebarLogo.value = value; + break; + case "layout": + layout.value = value; + break; + case "themeColor": + themeColor.value = value; + break; + default: + break; + } + } + + return { + showSettings, + tagsView, + fixedHeader, + sidebarLogo, + layout, + themeColor, + changeSetting, + }; +}); diff --git a/apps/frontend/src/store/modules/tagsView.ts b/apps/frontend/src/store/modules/tagsView.ts new file mode 100644 index 0000000..93c09a5 --- /dev/null +++ b/apps/frontend/src/store/modules/tagsView.ts @@ -0,0 +1,218 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { RouteLocationNormalized } from "vue-router"; + +export interface TagView extends Partial { + title?: string; +} + +// setup +export const useTagsViewStore = defineStore("tagsView", () => { + // state + const visitedViews = ref([]); + const cachedViews = ref([]); + + // actions + function addVisitedView(view: TagView) { + if (visitedViews.value.some((v) => v.path === view.path)) return; + if (view.meta && view.meta.affix) { + visitedViews.value.unshift( + Object.assign({}, view, { + title: view.meta?.title || "no-name", + }) + ); + } else { + visitedViews.value.push( + Object.assign({}, view, { + title: view.meta?.title || "no-name", + }) + ); + } + } + + function addCachedView(view: TagView) { + const viewName = view.name as string; + if (cachedViews.value.includes(viewName)) return; + if (view.meta?.keepAlive) { + cachedViews.value.push(viewName); + } + } + + function delVisitedView(view: TagView) { + return new Promise((resolve) => { + for (const [i, v] of visitedViews.value.entries()) { + if (v.path === view.path) { + visitedViews.value.splice(i, 1); + break; + } + } + resolve([...visitedViews.value]); + }); + } + + function delCachedView(view: TagView) { + const viewName = view.name as string; + return new Promise((resolve) => { + const index = cachedViews.value.indexOf(viewName); + index > -1 && cachedViews.value.splice(index, 1); + resolve([...cachedViews.value]); + }); + } + + function delOtherVisitedViews(view: TagView) { + return new Promise((resolve) => { + visitedViews.value = visitedViews.value.filter((v) => { + return v.meta?.affix || v.path === view.path; + }); + resolve([...visitedViews.value]); + }); + } + + function delOtherCachedViews(view: TagView) { + const viewName = view.name as string; + return new Promise((resolve) => { + const index = cachedViews.value.indexOf(viewName); + if (index > -1) { + cachedViews.value = cachedViews.value.slice(index, index + 1); + } else { + // if index = -1, there is no cached tags + cachedViews.value = []; + } + resolve([...cachedViews.value]); + }); + } + + function updateVisitedView(view: TagView) { + for (let v of visitedViews.value) { + if (v.path === view.path) { + v = Object.assign(v, view); + break; + } + } + } + + function addView(view: TagView) { + addVisitedView(view); + addCachedView(view); + } + + function delView(view: TagView) { + return new Promise((resolve) => { + delVisitedView(view); + delCachedView(view); + resolve({ + visitedViews: [...visitedViews.value], + cachedViews: [...cachedViews.value], + }); + }); + } + + function delOtherViews(view: TagView) { + return new Promise((resolve) => { + delOtherVisitedViews(view); + delOtherCachedViews(view); + resolve({ + visitedViews: [...visitedViews.value], + cachedViews: [...cachedViews.value], + }); + }); + } + + function delLeftViews(view: TagView) { + return new Promise((resolve) => { + const currIndex = visitedViews.value.findIndex( + (v) => v.path === view.path + ); + if (currIndex === -1) { + return; + } + visitedViews.value = visitedViews.value.filter((item, index) => { + // affix:true 固定tag,例如“首页” + if (index >= currIndex || (item.meta && item.meta.affix)) { + return true; + } + + const cacheIndex = cachedViews.value.indexOf(item.name as string); + if (cacheIndex > -1) { + cachedViews.value.splice(cacheIndex, 1); + } + return false; + }); + resolve({ + visitedViews: [...visitedViews.value], + }); + }); + } + function delRightViews(view: TagView) { + return new Promise((resolve) => { + const currIndex = visitedViews.value.findIndex( + (v) => v.path === view.path + ); + if (currIndex === -1) { + return; + } + visitedViews.value = visitedViews.value.filter((item, index) => { + // affix:true 固定tag,例如“首页” + if (index <= currIndex || (item.meta && item.meta.affix)) { + return true; + } + + const cacheIndex = cachedViews.value.indexOf(item.name as string); + if (cacheIndex > -1) { + cachedViews.value.splice(cacheIndex, 1); + } + return false; + }); + resolve({ + visitedViews: [...visitedViews.value], + }); + }); + } + + function delAllViews() { + return new Promise((resolve) => { + const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix); + visitedViews.value = affixTags; + cachedViews.value = []; + resolve({ + visitedViews: [...visitedViews.value], + cachedViews: [...cachedViews.value], + }); + }); + } + + function delAllVisitedViews() { + return new Promise((resolve) => { + const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix); + visitedViews.value = affixTags; + resolve([...visitedViews.value]); + }); + } + + function delAllCachedViews() { + return new Promise((resolve) => { + cachedViews.value = []; + resolve([...cachedViews.value]); + }); + } + + return { + visitedViews, + cachedViews, + addVisitedView, + addCachedView, + delVisitedView, + delCachedView, + delOtherVisitedViews, + delOtherCachedViews, + updateVisitedView, + addView, + delView, + delOtherViews, + delLeftViews, + delRightViews, + delAllViews, + delAllVisitedViews, + delAllCachedViews, + }; +}); diff --git a/apps/frontend/src/styles/dark.scss b/apps/frontend/src/styles/dark.scss new file mode 100644 index 0000000..5b719ff --- /dev/null +++ b/apps/frontend/src/styles/dark.scss @@ -0,0 +1,33 @@ +html.dark { + --menuBg: var(--el-bg-color-overlay); + --menuText: #fff; + --menuActiveText: var(--el-menu-active-color); + --menuHover: rgb(0 0 0 / 20%); + --subMenuBg: var(--el-menu-bg-color); + --subMenuActiveText: var(--el-menu-active-color); + --subMenuHover: rgb(0 0 0 / 20%); + + .navbar { + color: var(--el-text-color-regular); + background-color: var(--el-bg-color); + + .setting-container .setting-item:hover { + background: var(--el-fill-color-light); + } + } + + .right-panel-btn { + background-color: var(--el-color-primary-dark); + } + + .svg-icon, + svg { + fill: var(--el-text-color-regular); + } + + .sidebar-container { + .el-menu-item.is-active .svg-icon { + fill: var(--el-color-primary); + } + } +} diff --git a/apps/frontend/src/styles/element-plus.scss b/apps/frontend/src/styles/element-plus.scss new file mode 100644 index 0000000..65008af --- /dev/null +++ b/apps/frontend/src/styles/element-plus.scss @@ -0,0 +1,48 @@ +:root { + // 这里可以设置你自定义的颜色变量 + // 这个是element主要按钮:active的颜色,当主题更改后此变量的值也随之更改 + --el-color-primary-dark: #0d84ff; +} + +// 覆盖 element-plus 的样式 +.el-breadcrumb__inner, +.el-breadcrumb__inner a { + font-weight: 400 !important; +} + +.el-upload { + input[type="file"] { + display: none !important; + } +} + +.el-upload__input { + display: none; +} + +// dropdown +.el-dropdown-menu { + a { + display: block; + } +} + +// to fix el-date-picker css style +.el-range-separator { + box-sizing: content-box; +} + +// 选中行背景色值 +.el-table__body tr.current-row td { + background-color: #e1f3d8b5 !important; +} + +// card 的header统一高度 +.el-card__header { + height: 60px !important; +} + +// 表格表头和表体未对齐 +.el-table__header col[name="gutter"] { + display: table-cell !important; +} diff --git a/apps/frontend/src/styles/index.scss b/apps/frontend/src/styles/index.scss new file mode 100644 index 0000000..0b5b9e5 --- /dev/null +++ b/apps/frontend/src/styles/index.scss @@ -0,0 +1,17 @@ +@import "./sidebar"; +@import "./reset"; +@import "./dark"; +@import "./element-plus"; + +.app-container { + margin: 20px; + + .search { + padding: 18px 0 0 10px; + margin-bottom: 10px; + background-color: var(--el-bg-color-overlay); + border: 1px solid var(--el-border-color-light); + border-radius: 4px; + box-shadow: var(--el-box-shadow-light); + } +} diff --git a/apps/frontend/src/styles/reset.scss b/apps/frontend/src/styles/reset.scss new file mode 100644 index 0000000..9b19e4c --- /dev/null +++ b/apps/frontend/src/styles/reset.scss @@ -0,0 +1,75 @@ +*, +::before, +::after { + box-sizing: border-box; + border-color: currentcolor; + border-style: solid; + border-width: 0; +} + +#app { + width: 100%; + height: 100%; +} + +html { + box-sizing: border-box; + width: 100%; + height: 100%; + line-height: 1.5; + tab-size: 4; + text-size-adjust: 100%; +} + +body { + width: 100%; + height: 100%; + margin: 0; + font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", + "Microsoft YaHei", "微软雅黑", Arial, sans-serif; + line-height: inherit; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + text-rendering: optimizelegibility; +} + +a { + color: inherit; + text-decoration: inherit; +} + +img, +svg { + display: inline-block; +} + +svg { + vertical-align: -0.15em; //因icon大小被设置为和字体大小一致,而span等标签的下边缘会和字体的基线对齐,故需设置一个往下的偏移比例,来纠正视觉上的未对齐效果 +} + +ul, +li { + padding: 0; + margin: 0; + list-style: none; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +a, +a:focus, +a:hover { + color: inherit; + text-decoration: none; + cursor: pointer; +} + +a:focus, +a:active, +div:focus { + outline: none; +} diff --git a/apps/frontend/src/styles/sidebar.scss b/apps/frontend/src/styles/sidebar.scss new file mode 100644 index 0000000..efaa4e9 --- /dev/null +++ b/apps/frontend/src/styles/sidebar.scss @@ -0,0 +1,205 @@ +#app { + .main-container { + position: relative; + min-height: 100%; + margin-left: $sideBarWidth; + transition: margin-left 0.28s; + } + + .sidebar-container { + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 1001; + width: $sideBarWidth !important; + height: 100%; + overflow: hidden; + background-color: $menuBg; + transition: width 0.28s; + + // reset element-ui css + .horizontal-collapse-transition { + transition: 0s width ease-in-out, 0s padding-left ease-in-out, + 0s padding-right ease-in-out; + } + + .scrollbar-wrapper { + overflow-x: hidden !important; + } + + .el-scrollbar__bar.is-vertical { + right: 0; + } + + .el-scrollbar { + height: 100%; + } + + &.has-logo { + .el-scrollbar { + height: calc(100% - 50px); + } + } + + .is-horizontal { + display: none; + } + + .svg-icon { + margin-right: 16px; + } + + .sub-el-icon { + margin-right: 12px; + margin-left: -2px; + } + + .el-menu { + width: 100% !important; + height: 100%; + border: none; + } + + // menu hover + .el-sub-menu__title { + &:hover { + background-color: $menuHover !important; + } + } + + .is-active > .el-sub-menu__title { + color: $subMenuActiveText !important; + } + + & .nest-menu .el-sub-menu > .el-sub-menu__title, + & .el-sub-menu .el-menu-item { + min-width: $sideBarWidth !important; + background-color: $subMenuBg !important; + + &:hover { + background-color: $subMenuHover !important; + } + } + } + + .hideSidebar { + .sidebar-container { + width: 54px !important; + + .svg-icon { + margin-right: 0; + } + } + + .main-container { + margin-left: 54px; + } + + .el-sub-menu { + overflow: hidden; + + & > .el-sub-menu__title { + padding: 0 !important; + + .svg-icon { + margin-left: 20px; + } + + .sub-el-icon { + margin-left: 19px; + } + + .el-sub-menu__icon-arrow { + display: none; + } + } + } + + .el-menu--collapse { + .el-sub-menu { + & > .el-sub-menu__title { + & > span { + display: inline-block; + width: 0; + height: 0; + overflow: hidden; + visibility: hidden; + } + } + } + } + } + + .el-menu--collapse .el-menu .el-sub-menu { + min-width: $sideBarWidth !important; + } + + // mobile responsive + .mobile { + .main-container { + margin-left: 0; + } + + .sidebar-container { + width: $sideBarWidth !important; + transition: transform 0.28s; + } + + &.hideSidebar { + .sidebar-container { + pointer-events: none; + transition-duration: 0.3s; + transform: translate3d(-$sideBarWidth, 0, 0); + } + } + } + + .withoutAnimation { + .main-container, + .sidebar-container { + transition: none; + } + } +} + +// when menu collapsed +.el-menu--vertical { + & > .el-menu { + .svg-icon { + margin-right: 16px; + } + + .sub-el-icon { + margin-right: 12px; + margin-left: -2px; + } + } + + .nest-menu .el-sub-menu > .el-sub-menu__title, + .el-menu-item { + &:hover { + // you can use $subMenuHover + background-color: $menuHover !important; + } + } + + // the scroll bar appears when the subMenu is too long + > .el-menu--popup { + max-height: 100vh; + overflow-y: auto; + + &::-webkit-scrollbar-track-piece { + background: #d3dce6; + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #99a9bf; + border-radius: 20px; + } + } +} diff --git a/apps/frontend/src/styles/variables.module.scss b/apps/frontend/src/styles/variables.module.scss new file mode 100644 index 0000000..7feccc4 --- /dev/null +++ b/apps/frontend/src/styles/variables.module.scss @@ -0,0 +1,6 @@ +// 导出 variables.module.scss 变量提供给TypeScript使用 +:export { + menuBg: $menuBg; + menuText: $menuText; + menuActiveText: $menuActiveText; +} diff --git a/apps/frontend/src/styles/variables.scss b/apps/frontend/src/styles/variables.scss new file mode 100644 index 0000000..e11df30 --- /dev/null +++ b/apps/frontend/src/styles/variables.scss @@ -0,0 +1,22 @@ +// 全局SCSS变量 + +:root { + --menuBg: #304156; + --menuText: #bfcbd9; + --menuActiveText: #409eff; + --menuHover: #263445; + --subMenuBg: #1f2d3d; + --subMenuActiveText: #f4f4f5; + --subMenuHover: #001528; +} + +$menuBg: var(--menuBg); +$menuText: var(--menuText); +$menuActiveText: var(--menuActiveText); +$menuHover: var(--menuHover); + +$subMenuBg: var(--subMenuBg); +$subMenuActiveText: var(--subMenuActiveText); +$subMenuHover: var(--subMenuHover); + +$sideBarWidth: 210px; diff --git a/apps/frontend/src/types/auto-imports.d.ts b/apps/frontend/src/types/auto-imports.d.ts new file mode 100644 index 0000000..90306a1 --- /dev/null +++ b/apps/frontend/src/types/auto-imports.d.ts @@ -0,0 +1,532 @@ +// Generated by 'unplugin-auto-import' +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const ElForm: typeof import('element-plus/es')['ElForm'] + const ElInput: typeof import('element-plus/es')['ElInput'] + const ElMessage: typeof import('element-plus/es')['ElMessage'] + const ElMessageBox: typeof import('element-plus/es')['ElMessageBox'] + const ElNotification: typeof import('element-plus/es')['ElNotification'] + const ElSelect: typeof import('element-plus/es')['ElSelect'] + const asyncComputed: typeof import('@vueuse/core')['asyncComputed'] + const autoResetRef: typeof import('@vueuse/core')['autoResetRef'] + const computed: typeof import('vue')['computed'] + const computedAsync: typeof import('@vueuse/core')['computedAsync'] + const computedEager: typeof import('@vueuse/core')['computedEager'] + const computedInject: typeof import('@vueuse/core')['computedInject'] + const computedWithControl: typeof import('@vueuse/core')['computedWithControl'] + const controlledComputed: typeof import('@vueuse/core')['controlledComputed'] + const controlledRef: typeof import('@vueuse/core')['controlledRef'] + const createApp: typeof import('vue')['createApp'] + const createEventHook: typeof import('@vueuse/core')['createEventHook'] + const createGlobalState: typeof import('@vueuse/core')['createGlobalState'] + const createInjectionState: typeof import('@vueuse/core')['createInjectionState'] + const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn'] + const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable'] + const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn'] + const customRef: typeof import('vue')['customRef'] + const debouncedRef: typeof import('@vueuse/core')['debouncedRef'] + const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const eagerComputed: typeof import('@vueuse/core')['eagerComputed'] + const effectScope: typeof import('vue')['effectScope'] + const extendRef: typeof import('@vueuse/core')['extendRef'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch'] + const inject: typeof import('vue')['inject'] + const isDefined: typeof import('@vueuse/core')['isDefined'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onClickOutside: typeof import('@vueuse/core')['onClickOutside'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke'] + const onLongPress: typeof import('@vueuse/core')['onLongPress'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onStartTyping: typeof import('@vueuse/core')['onStartTyping'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const pausableWatch: typeof import('@vueuse/core')['pausableWatch'] + const provide: typeof import('vue')['provide'] + const reactify: typeof import('@vueuse/core')['reactify'] + const reactifyObject: typeof import('@vueuse/core')['reactifyObject'] + const reactive: typeof import('vue')['reactive'] + const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed'] + const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit'] + const reactivePick: typeof import('@vueuse/core')['reactivePick'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const refAutoReset: typeof import('@vueuse/core')['refAutoReset'] + const refDebounced: typeof import('@vueuse/core')['refDebounced'] + const refDefault: typeof import('@vueuse/core')['refDefault'] + const refThrottled: typeof import('@vueuse/core')['refThrottled'] + const refWithControl: typeof import('@vueuse/core')['refWithControl'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const resolveDirective: typeof import('vue')['resolveDirective'] + const resolveRef: typeof import('@vueuse/core')['resolveRef'] + const resolveUnref: typeof import('@vueuse/core')['resolveUnref'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const syncRef: typeof import('@vueuse/core')['syncRef'] + const syncRefs: typeof import('@vueuse/core')['syncRefs'] + const templateRef: typeof import('@vueuse/core')['templateRef'] + const throttledRef: typeof import('@vueuse/core')['throttledRef'] + const throttledWatch: typeof import('@vueuse/core')['throttledWatch'] + const toRaw: typeof import('vue')['toRaw'] + const toReactive: typeof import('@vueuse/core')['toReactive'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const triggerRef: typeof import('vue')['triggerRef'] + const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount'] + const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount'] + const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted'] + const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose'] + const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted'] + const unref: typeof import('vue')['unref'] + const unrefElement: typeof import('@vueuse/core')['unrefElement'] + const until: typeof import('@vueuse/core')['until'] + const useActiveElement: typeof import('@vueuse/core')['useActiveElement'] + const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery'] + const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter'] + const useArrayFind: typeof import('@vueuse/core')['useArrayFind'] + const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex'] + const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin'] + const useArrayMap: typeof import('@vueuse/core')['useArrayMap'] + const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce'] + const useArraySome: typeof import('@vueuse/core')['useArraySome'] + const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue'] + const useAsyncState: typeof import('@vueuse/core')['useAsyncState'] + const useAttrs: typeof import('vue')['useAttrs'] + const useBase64: typeof import('@vueuse/core')['useBase64'] + const useBattery: typeof import('@vueuse/core')['useBattery'] + const useBluetooth: typeof import('@vueuse/core')['useBluetooth'] + const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints'] + const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel'] + const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation'] + const useCached: typeof import('@vueuse/core')['useCached'] + const useClipboard: typeof import('@vueuse/core')['useClipboard'] + const useColorMode: typeof import('@vueuse/core')['useColorMode'] + const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog'] + const useCounter: typeof import('@vueuse/core')['useCounter'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVar: typeof import('@vueuse/core')['useCssVar'] + const useCssVars: typeof import('vue')['useCssVars'] + const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement'] + const useCycleList: typeof import('@vueuse/core')['useCycleList'] + const useDark: typeof import('@vueuse/core')['useDark'] + const useDateFormat: typeof import('@vueuse/core')['useDateFormat'] + const useDebounce: typeof import('@vueuse/core')['useDebounce'] + const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn'] + const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory'] + const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion'] + const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation'] + const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio'] + const useDevicesList: typeof import('@vueuse/core')['useDevicesList'] + const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia'] + const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility'] + const useDraggable: typeof import('@vueuse/core')['useDraggable'] + const useDropZone: typeof import('@vueuse/core')['useDropZone'] + const useElementBounding: typeof import('@vueuse/core')['useElementBounding'] + const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint'] + const useElementHover: typeof import('@vueuse/core')['useElementHover'] + const useElementSize: typeof import('@vueuse/core')['useElementSize'] + const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility'] + const useEventBus: typeof import('@vueuse/core')['useEventBus'] + const useEventListener: typeof import('@vueuse/core')['useEventListener'] + const useEventSource: typeof import('@vueuse/core')['useEventSource'] + const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper'] + const useFavicon: typeof import('@vueuse/core')['useFavicon'] + const useFetch: typeof import('@vueuse/core')['useFetch'] + const useFileDialog: typeof import('@vueuse/core')['useFileDialog'] + const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess'] + const useFocus: typeof import('@vueuse/core')['useFocus'] + const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin'] + const useFps: typeof import('@vueuse/core')['useFps'] + const useFullscreen: typeof import('@vueuse/core')['useFullscreen'] + const useGamepad: typeof import('@vueuse/core')['useGamepad'] + const useGeolocation: typeof import('@vueuse/core')['useGeolocation'] + const useIdle: typeof import('@vueuse/core')['useIdle'] + const useImage: typeof import('@vueuse/core')['useImage'] + const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll'] + const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver'] + const useInterval: typeof import('@vueuse/core')['useInterval'] + const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn'] + const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier'] + const useLastChanged: typeof import('@vueuse/core')['useLastChanged'] + const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage'] + const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys'] + const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory'] + const useMediaControls: typeof import('@vueuse/core')['useMediaControls'] + const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery'] + const useMemoize: typeof import('@vueuse/core')['useMemoize'] + const useMemory: typeof import('@vueuse/core')['useMemory'] + const useMounted: typeof import('@vueuse/core')['useMounted'] + const useMouse: typeof import('@vueuse/core')['useMouse'] + const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement'] + const useMousePressed: typeof import('@vueuse/core')['useMousePressed'] + const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver'] + const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage'] + const useNetwork: typeof import('@vueuse/core')['useNetwork'] + const useNow: typeof import('@vueuse/core')['useNow'] + const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl'] + const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination'] + const useOnline: typeof import('@vueuse/core')['useOnline'] + const usePageLeave: typeof import('@vueuse/core')['usePageLeave'] + const useParallax: typeof import('@vueuse/core')['useParallax'] + const usePermission: typeof import('@vueuse/core')['usePermission'] + const usePointer: typeof import('@vueuse/core')['usePointer'] + const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe'] + const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme'] + const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast'] + const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark'] + const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages'] + const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion'] + const useRafFn: typeof import('@vueuse/core')['useRafFn'] + const useRefHistory: typeof import('@vueuse/core')['useRefHistory'] + const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver'] + const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation'] + const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea'] + const useScriptTag: typeof import('@vueuse/core')['useScriptTag'] + const useScroll: typeof import('@vueuse/core')['useScroll'] + const useScrollLock: typeof import('@vueuse/core')['useScrollLock'] + const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage'] + const useShare: typeof import('@vueuse/core')['useShare'] + const useSlots: typeof import('vue')['useSlots'] + const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition'] + const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis'] + const useStepper: typeof import('@vueuse/core')['useStepper'] + const useStorage: typeof import('@vueuse/core')['useStorage'] + const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync'] + const useStyleTag: typeof import('@vueuse/core')['useStyleTag'] + const useSupported: typeof import('@vueuse/core')['useSupported'] + const useSwipe: typeof import('@vueuse/core')['useSwipe'] + const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList'] + const useTextDirection: typeof import('@vueuse/core')['useTextDirection'] + const useTextSelection: typeof import('@vueuse/core')['useTextSelection'] + const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize'] + const useThrottle: typeof import('@vueuse/core')['useThrottle'] + const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn'] + const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory'] + const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo'] + const useTimeout: typeof import('@vueuse/core')['useTimeout'] + const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn'] + const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll'] + const useTimestamp: typeof import('@vueuse/core')['useTimestamp'] + const useTitle: typeof import('@vueuse/core')['useTitle'] + const useToNumber: typeof import('@vueuse/core')['useToNumber'] + const useToString: typeof import('@vueuse/core')['useToString'] + const useToggle: typeof import('@vueuse/core')['useToggle'] + const useTransition: typeof import('@vueuse/core')['useTransition'] + const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams'] + const useUserMedia: typeof import('@vueuse/core')['useUserMedia'] + const useVModel: typeof import('@vueuse/core')['useVModel'] + const useVModels: typeof import('@vueuse/core')['useVModels'] + const useVibrate: typeof import('@vueuse/core')['useVibrate'] + const useVirtualList: typeof import('@vueuse/core')['useVirtualList'] + const useWakeLock: typeof import('@vueuse/core')['useWakeLock'] + const useWebNotification: typeof import('@vueuse/core')['useWebNotification'] + const useWebSocket: typeof import('@vueuse/core')['useWebSocket'] + const useWebWorker: typeof import('@vueuse/core')['useWebWorker'] + const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn'] + const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus'] + const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll'] + const useWindowSize: typeof import('@vueuse/core')['useWindowSize'] + const watch: typeof import('vue')['watch'] + const watchArray: typeof import('@vueuse/core')['watchArray'] + const watchAtMost: typeof import('@vueuse/core')['watchAtMost'] + const watchDebounced: typeof import('@vueuse/core')['watchDebounced'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable'] + const watchOnce: typeof import('@vueuse/core')['watchOnce'] + const watchPausable: typeof import('@vueuse/core')['watchPausable'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] + const watchThrottled: typeof import('@vueuse/core')['watchThrottled'] + const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable'] + const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter'] + const whenever: typeof import('@vueuse/core')['whenever'] +} +// for vue template auto import +import { UnwrapRef } from 'vue' +declare module 'vue' { + interface ComponentCustomProperties { + readonly EffectScope: UnwrapRef + readonly ElForm: UnwrapRef + readonly ElInput: UnwrapRef + readonly ElMessage: UnwrapRef + readonly ElMessageBox: UnwrapRef + readonly ElNotification: UnwrapRef + readonly ElSelect: UnwrapRef + readonly asyncComputed: UnwrapRef + readonly autoResetRef: UnwrapRef + readonly computed: UnwrapRef + readonly computedAsync: UnwrapRef + readonly computedEager: UnwrapRef + readonly computedInject: UnwrapRef + readonly computedWithControl: UnwrapRef + readonly controlledComputed: UnwrapRef + readonly controlledRef: UnwrapRef + readonly createApp: UnwrapRef + readonly createEventHook: UnwrapRef + readonly createGlobalState: UnwrapRef + readonly createInjectionState: UnwrapRef + readonly createReactiveFn: UnwrapRef + readonly createSharedComposable: UnwrapRef + readonly createUnrefFn: UnwrapRef + readonly customRef: UnwrapRef + readonly debouncedRef: UnwrapRef + readonly debouncedWatch: UnwrapRef + readonly defineAsyncComponent: UnwrapRef + readonly defineComponent: UnwrapRef + readonly eagerComputed: UnwrapRef + readonly effectScope: UnwrapRef + readonly extendRef: UnwrapRef + readonly getCurrentInstance: UnwrapRef + readonly getCurrentScope: UnwrapRef + readonly h: UnwrapRef + readonly ignorableWatch: UnwrapRef + readonly inject: UnwrapRef + readonly isDefined: UnwrapRef + readonly isProxy: UnwrapRef + readonly isReactive: UnwrapRef + readonly isReadonly: UnwrapRef + readonly isRef: UnwrapRef + readonly makeDestructurable: UnwrapRef + readonly markRaw: UnwrapRef + readonly nextTick: UnwrapRef + readonly onActivated: UnwrapRef + readonly onBeforeMount: UnwrapRef + readonly onBeforeUnmount: UnwrapRef + readonly onBeforeUpdate: UnwrapRef + readonly onClickOutside: UnwrapRef + readonly onDeactivated: UnwrapRef + readonly onErrorCaptured: UnwrapRef + readonly onKeyStroke: UnwrapRef + readonly onLongPress: UnwrapRef + readonly onMounted: UnwrapRef + readonly onRenderTracked: UnwrapRef + readonly onRenderTriggered: UnwrapRef + readonly onScopeDispose: UnwrapRef + readonly onServerPrefetch: UnwrapRef + readonly onStartTyping: UnwrapRef + readonly onUnmounted: UnwrapRef + readonly onUpdated: UnwrapRef + readonly pausableWatch: UnwrapRef + readonly provide: UnwrapRef + readonly reactify: UnwrapRef + readonly reactifyObject: UnwrapRef + readonly reactive: UnwrapRef + readonly reactiveComputed: UnwrapRef + readonly reactiveOmit: UnwrapRef + readonly reactivePick: UnwrapRef + readonly readonly: UnwrapRef + readonly ref: UnwrapRef + readonly refAutoReset: UnwrapRef + readonly refDebounced: UnwrapRef + readonly refDefault: UnwrapRef + readonly refThrottled: UnwrapRef + readonly refWithControl: UnwrapRef + readonly resolveComponent: UnwrapRef + readonly resolveDirective: UnwrapRef + readonly resolveRef: UnwrapRef + readonly resolveUnref: UnwrapRef + readonly shallowReactive: UnwrapRef + readonly shallowReadonly: UnwrapRef + readonly shallowRef: UnwrapRef + readonly syncRef: UnwrapRef + readonly syncRefs: UnwrapRef + readonly templateRef: UnwrapRef + readonly throttledRef: UnwrapRef + readonly throttledWatch: UnwrapRef + readonly toRaw: UnwrapRef + readonly toReactive: UnwrapRef + readonly toRef: UnwrapRef + readonly toRefs: UnwrapRef + readonly triggerRef: UnwrapRef + readonly tryOnBeforeMount: UnwrapRef + readonly tryOnBeforeUnmount: UnwrapRef + readonly tryOnMounted: UnwrapRef + readonly tryOnScopeDispose: UnwrapRef + readonly tryOnUnmounted: UnwrapRef + readonly unref: UnwrapRef + readonly unrefElement: UnwrapRef + readonly until: UnwrapRef + readonly useActiveElement: UnwrapRef + readonly useArrayEvery: UnwrapRef + readonly useArrayFilter: UnwrapRef + readonly useArrayFind: UnwrapRef + readonly useArrayFindIndex: UnwrapRef + readonly useArrayJoin: UnwrapRef + readonly useArrayMap: UnwrapRef + readonly useArrayReduce: UnwrapRef + readonly useArraySome: UnwrapRef + readonly useAsyncQueue: UnwrapRef + readonly useAsyncState: UnwrapRef + readonly useAttrs: UnwrapRef + readonly useBase64: UnwrapRef + readonly useBattery: UnwrapRef + readonly useBluetooth: UnwrapRef + readonly useBreakpoints: UnwrapRef + readonly useBroadcastChannel: UnwrapRef + readonly useBrowserLocation: UnwrapRef + readonly useCached: UnwrapRef + readonly useClipboard: UnwrapRef + readonly useColorMode: UnwrapRef + readonly useConfirmDialog: UnwrapRef + readonly useCounter: UnwrapRef + readonly useCssModule: UnwrapRef + readonly useCssVar: UnwrapRef + readonly useCssVars: UnwrapRef + readonly useCurrentElement: UnwrapRef + readonly useCycleList: UnwrapRef + readonly useDark: UnwrapRef + readonly useDateFormat: UnwrapRef + readonly useDebounce: UnwrapRef + readonly useDebounceFn: UnwrapRef + readonly useDebouncedRefHistory: UnwrapRef + readonly useDeviceMotion: UnwrapRef + readonly useDeviceOrientation: UnwrapRef + readonly useDevicePixelRatio: UnwrapRef + readonly useDevicesList: UnwrapRef + readonly useDisplayMedia: UnwrapRef + readonly useDocumentVisibility: UnwrapRef + readonly useDraggable: UnwrapRef + readonly useDropZone: UnwrapRef + readonly useElementBounding: UnwrapRef + readonly useElementByPoint: UnwrapRef + readonly useElementHover: UnwrapRef + readonly useElementSize: UnwrapRef + readonly useElementVisibility: UnwrapRef + readonly useEventBus: UnwrapRef + readonly useEventListener: UnwrapRef + readonly useEventSource: UnwrapRef + readonly useEyeDropper: UnwrapRef + readonly useFavicon: UnwrapRef + readonly useFetch: UnwrapRef + readonly useFileDialog: UnwrapRef + readonly useFileSystemAccess: UnwrapRef + readonly useFocus: UnwrapRef + readonly useFocusWithin: UnwrapRef + readonly useFps: UnwrapRef + readonly useFullscreen: UnwrapRef + readonly useGamepad: UnwrapRef + readonly useGeolocation: UnwrapRef + readonly useIdle: UnwrapRef + readonly useImage: UnwrapRef + readonly useInfiniteScroll: UnwrapRef + readonly useIntersectionObserver: UnwrapRef + readonly useInterval: UnwrapRef + readonly useIntervalFn: UnwrapRef + readonly useKeyModifier: UnwrapRef + readonly useLastChanged: UnwrapRef + readonly useLocalStorage: UnwrapRef + readonly useMagicKeys: UnwrapRef + readonly useManualRefHistory: UnwrapRef + readonly useMediaControls: UnwrapRef + readonly useMediaQuery: UnwrapRef + readonly useMemoize: UnwrapRef + readonly useMemory: UnwrapRef + readonly useMounted: UnwrapRef + readonly useMouse: UnwrapRef + readonly useMouseInElement: UnwrapRef + readonly useMousePressed: UnwrapRef + readonly useMutationObserver: UnwrapRef + readonly useNavigatorLanguage: UnwrapRef + readonly useNetwork: UnwrapRef + readonly useNow: UnwrapRef + readonly useObjectUrl: UnwrapRef + readonly useOffsetPagination: UnwrapRef + readonly useOnline: UnwrapRef + readonly usePageLeave: UnwrapRef + readonly useParallax: UnwrapRef + readonly usePermission: UnwrapRef + readonly usePointer: UnwrapRef + readonly usePointerSwipe: UnwrapRef + readonly usePreferredColorScheme: UnwrapRef + readonly usePreferredContrast: UnwrapRef + readonly usePreferredDark: UnwrapRef + readonly usePreferredLanguages: UnwrapRef + readonly usePreferredReducedMotion: UnwrapRef + readonly useRafFn: UnwrapRef + readonly useRefHistory: UnwrapRef + readonly useResizeObserver: UnwrapRef + readonly useScreenOrientation: UnwrapRef + readonly useScreenSafeArea: UnwrapRef + readonly useScriptTag: UnwrapRef + readonly useScroll: UnwrapRef + readonly useScrollLock: UnwrapRef + readonly useSessionStorage: UnwrapRef + readonly useShare: UnwrapRef + readonly useSlots: UnwrapRef + readonly useSpeechRecognition: UnwrapRef + readonly useSpeechSynthesis: UnwrapRef + readonly useStepper: UnwrapRef + readonly useStorage: UnwrapRef + readonly useStorageAsync: UnwrapRef + readonly useStyleTag: UnwrapRef + readonly useSupported: UnwrapRef + readonly useSwipe: UnwrapRef + readonly useTemplateRefsList: UnwrapRef + readonly useTextDirection: UnwrapRef + readonly useTextSelection: UnwrapRef + readonly useTextareaAutosize: UnwrapRef + readonly useThrottle: UnwrapRef + readonly useThrottleFn: UnwrapRef + readonly useThrottledRefHistory: UnwrapRef + readonly useTimeAgo: UnwrapRef + readonly useTimeout: UnwrapRef + readonly useTimeoutFn: UnwrapRef + readonly useTimeoutPoll: UnwrapRef + readonly useTimestamp: UnwrapRef + readonly useTitle: UnwrapRef + readonly useToNumber: UnwrapRef + readonly useToString: UnwrapRef + readonly useToggle: UnwrapRef + readonly useTransition: UnwrapRef + readonly useUrlSearchParams: UnwrapRef + readonly useUserMedia: UnwrapRef + readonly useVModel: UnwrapRef + readonly useVModels: UnwrapRef + readonly useVibrate: UnwrapRef + readonly useVirtualList: UnwrapRef + readonly useWakeLock: UnwrapRef + readonly useWebNotification: UnwrapRef + readonly useWebSocket: UnwrapRef + readonly useWebWorker: UnwrapRef + readonly useWebWorkerFn: UnwrapRef + readonly useWindowFocus: UnwrapRef + readonly useWindowScroll: UnwrapRef + readonly useWindowSize: UnwrapRef + readonly watch: UnwrapRef + readonly watchArray: UnwrapRef + readonly watchAtMost: UnwrapRef + readonly watchDebounced: UnwrapRef + readonly watchEffect: UnwrapRef + readonly watchIgnorable: UnwrapRef + readonly watchOnce: UnwrapRef + readonly watchPausable: UnwrapRef + readonly watchPostEffect: UnwrapRef + readonly watchSyncEffect: UnwrapRef + readonly watchThrottled: UnwrapRef + readonly watchTriggerable: UnwrapRef + readonly watchWithFilter: UnwrapRef + readonly whenever: UnwrapRef + } +} diff --git a/apps/frontend/src/types/components.d.ts b/apps/frontend/src/types/components.d.ts new file mode 100644 index 0000000..1412768 --- /dev/null +++ b/apps/frontend/src/types/components.d.ts @@ -0,0 +1,65 @@ +// generated by unplugin-vue-components +// We suggest you to commit this file into source control +// Read more: https://github.com/vuejs/core/pull/3399 +import '@vue/runtime-core' + +export {} + +declare module '@vue/runtime-core' { + export interface GlobalComponents { + ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElImage: typeof import('element-plus/es')['ElImage'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm'] + ElRadio: typeof import('element-plus/es')['ElRadio'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElTooltip: typeof import('element-plus/es')['ElTooltip'] + ElUpload: typeof import('element-plus/es')['ElUpload'] + Hamburger: typeof import('./../components/Hamburger/index.vue')['default'] + IEpCaretBottom: typeof import('~icons/ep/caret-bottom')['default'] + IEpClose: typeof import('~icons/ep/close')['default'] + IEpDownload: typeof import('~icons/ep/download')['default'] + IEpRefresh: typeof import('~icons/ep/refresh')['default'] + IEpRefreshRight: typeof import('~icons/ep/refresh-right')['default'] + IEpSetting: typeof import('~icons/ep/setting')['default'] + IEpUpload: typeof import('~icons/ep/upload')['default'] + ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default'] + LangSelect: typeof import('./../components/LangSelect/index.vue')['default'] + MapAdd: typeof import('./../components/MapAdd/index.vue')['default'] + Pagination: typeof import('./../components/Pagination/index.vue')['default'] + RightPanel: typeof import('./../components/RightPanel/index.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + SizeSelect: typeof import('./../components/SizeSelect/index.vue')['default'] + SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default'] + UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default'] + } + export interface ComponentCustomProperties { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/apps/frontend/src/types/env.d.ts b/apps/frontend/src/types/env.d.ts new file mode 100644 index 0000000..b74dc75 --- /dev/null +++ b/apps/frontend/src/types/env.d.ts @@ -0,0 +1,19 @@ +/// + +declare module "*.vue" { + import { DefineComponent } from "vue"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types + const component: DefineComponent<{}, {}, any>; + export default component; +} + +// 环境变量 TypeScript的智能提示 +interface ImportMetaEnv { + VITE_APP_TITLE: string; + VITE_APP_PORT: string; + VITE_APP_BASE_API: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/frontend/src/types/global.d.ts b/apps/frontend/src/types/global.d.ts new file mode 100644 index 0000000..a659e31 --- /dev/null +++ b/apps/frontend/src/types/global.d.ts @@ -0,0 +1,18 @@ +declare global { + interface IdDto { + id: number; + } + + interface BaseDto { + pageNum: number; + pageSize: number; + startTime?: string; + endTime?: string; + } + + interface PageVo { + records: T[]; + total: number; + } +} +export {}; diff --git a/apps/frontend/src/utils/byte.ts b/apps/frontend/src/utils/byte.ts new file mode 100644 index 0000000..1a60be3 --- /dev/null +++ b/apps/frontend/src/utils/byte.ts @@ -0,0 +1,95 @@ +/** + * 格式化字节大小 + * @param bytes 字节数 + * @param decimals 小数位数,默认为 2 + * @returns 格式化后的字节大小字符串 + */ +export const formatBytes = (bytes: number, decimals = 2): string => { + // 检查是否为特殊值 + if (bytes === -1) { + return "Unlimited"; + } + if (bytes === 0) { + return "0 Bytes"; + } + + // 计算单位和大小 + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + // 返回格式化后的字符串 + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; +}; + +export const calculateBytes = (value = 0, unit = "Bytes"): number => { + // 将单位转换为大写,并去除空格 + const formattedUnit = unit.toUpperCase().trim(); + + // 定义存储单位和对应的字节数的映射关系 + const unitToBytes: Record = { + BYTES: 1, + KB: 1024 ** 1, + MB: 1024 ** 2, + GB: 1024 ** 3, + TB: 1024 ** 4, + PB: 1024 ** 5, + EB: 1024 ** 6, + ZB: 1024 ** 7, + YB: 1024 ** 8, + }; + + // 检查传入的单位是否存在于映射关系中 + if (!Object.prototype.hasOwnProperty.call(unitToBytes, formattedUnit)) { + throw new Error("Invalid unit"); + } + + if (value == -1) { + return -1; + } + + // 计算并返回字节数 + return value * unitToBytes[formattedUnit]; +}; + +/** + * 格式化存储容量单位 + * @param bytes 存储容量(字节数) + * @param decimals 小数位数,默认为 2 + * @returns 格式化后的存储容量值 + */ +export const formatStorageCapacity = (bytes: number, decimals = 2): number => { + // 检查输入是否有效 + if (!bytes || bytes <= 0) { + return bytes; + } + + // 计算存储单位 + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + // 格式化存储容量值并返回 + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)); +}; + +/** + * 格式化存储容量单位 + * @param bytes 存储容量(字节数) + * @returns 格式化后的存储单位 + */ +export const formatStorageUnit = (bytes: number): string => { + // 检查输入是否有效 + if (!bytes || bytes <= 0) { + return "Bytes"; + } + + // 计算存储单位 + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + // 返回格式化后的存储单位 + return sizes[i]; +}; diff --git a/apps/frontend/src/utils/copy.ts b/apps/frontend/src/utils/copy.ts new file mode 100644 index 0000000..ad65f59 --- /dev/null +++ b/apps/frontend/src/utils/copy.ts @@ -0,0 +1,51 @@ +/** + * 浅拷贝,忽略 null,支持嵌套对象 + * @param target + * @param source + */ +export const assignWith = (target: T, source: Partial): void => { + if (source === null || typeof source !== "object") { + return; + } + + for (const key in source) { + if (source[key] !== null) { + if (typeof source[key] === "object") { + if (!target[key]) { + target[key] = (Array.isArray(source[key]) ? [] : {}) as T[Extract< + keyof T, + string + >]; + } + assignWith(target[key] as any, source[key] as any); + } else { + target[key] = source[key] as T[Extract]; + } + } + } +}; + +/** + * 深拷贝,忽略 null,支持嵌套对象 + * @param source + */ +export const deepCopy = (source: Partial): T => { + if (source === null || typeof source !== "object") { + return source; + } + + if (Array.isArray(source)) { + const arrCopy = [] as any[]; + source.forEach((item, index) => { + arrCopy[index] = deepCopy(item); + }); + return arrCopy as any; + } + + const objCopy = {} as { [key: string]: any }; + Object.keys(source).forEach((key) => { + objCopy[key] = deepCopy((source as { [key: string]: any })[key]); + }); + + return objCopy as T; +}; diff --git a/apps/frontend/src/utils/i18n.ts b/apps/frontend/src/utils/i18n.ts new file mode 100644 index 0000000..a2d094d --- /dev/null +++ b/apps/frontend/src/utils/i18n.ts @@ -0,0 +1,12 @@ +// translate router.meta.title, be used in breadcrumb sidebar tagsview +import i18n from "@/lang/index"; + +export function translateRouteTitleI18n(title: any) { + // 判断是否存在国际化配置,如果没有原生返回 + const hasKey = i18n.global.te("route." + title); + if (hasKey) { + const translatedTitle = i18n.global.t("route." + title); + return translatedTitle; + } + return title; +} diff --git a/apps/frontend/src/utils/index.ts b/apps/frontend/src/utils/index.ts new file mode 100644 index 0000000..0d62660 --- /dev/null +++ b/apps/frontend/src/utils/index.ts @@ -0,0 +1,39 @@ +/** + * Check if an element has a class + * @param {HTMLElement} ele + * @param {string} cls + * @returns {boolean} + */ +export function hasClass(ele: HTMLElement, cls: string) { + return !!ele.className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)")); +} + +/** + * Add class to element + * @param {HTMLElement} ele + * @param {string} cls + */ +export function addClass(ele: HTMLElement, cls: string) { + if (!hasClass(ele, cls)) ele.className += " " + cls; +} + +/** + * Remove class from element + * @param {HTMLElement} ele + * @param {string} cls + */ +export function removeClass(ele: HTMLElement, cls: string) { + if (hasClass(ele, cls)) { + const reg = new RegExp("(\\s|^)" + cls + "(\\s|$)"); + ele.className = ele.className.replace(reg, " "); + } +} + +/** + * @param {string} path + * @returns {Boolean} + */ +export function isExternal(path: string) { + const isExternal = /^(https?:|http?:|mailto:|tel:)/.test(path); + return isExternal; +} diff --git a/apps/frontend/src/utils/request.ts b/apps/frontend/src/utils/request.ts new file mode 100644 index 0000000..a0fa112 --- /dev/null +++ b/apps/frontend/src/utils/request.ts @@ -0,0 +1,62 @@ +import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios"; +import { useAccountStoreHook } from "@/store/modules/account"; + +const dynamicBase = (window as any).__dynamic_base__ || ""; +// 创建 axios 实例 +const service = axios.create({ + baseURL: `${dynamicBase}${import.meta.env.VITE_APP_BASE_API}`, + timeout: 50000, + headers: { "Content-Type": "application/json;charset=utf-8" }, +}); + +// 请求拦截器 +service.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const accountStore = useAccountStoreHook(); + if (accountStore.token) { + config.headers.Authorization = accountStore.token; + } + return config; + }, + (error: any) => { + return Promise.reject(error); + } +); + +// 响应拦截器 +service.interceptors.response.use( + (response: AxiosResponse) => { + const { code, message } = response.data; + if (code === 20000) { + return response.data; + } + // 响应数据为二进制流处理(文件导出) + if (response.data instanceof ArrayBuffer || response.data instanceof Blob) { + return response; + } + + ElMessage.error(message || "系统出错"); + return Promise.reject(new Error(message || "Error")); + }, + (error: any) => { + if (error.response.data) { + const { code, msg } = error.response.data; + // token 过期,重新登录 + if (code === "A0230") { + ElMessageBox.confirm("当前页面已失效,请重新登录", "提示", { + confirmButtonText: "确定", + type: "warning", + }).then(() => { + localStorage.clear(); + window.location.href = "/"; + }); + } else { + ElMessage.error(msg || "系统出错"); + } + } + return Promise.reject(error.message); + } +); + +// 导出 axios 实例 +export default service; diff --git a/apps/frontend/src/utils/scroll-to.ts b/apps/frontend/src/utils/scroll-to.ts new file mode 100644 index 0000000..c4e48fc --- /dev/null +++ b/apps/frontend/src/utils/scroll-to.ts @@ -0,0 +1,69 @@ +const easeInOutQuad = (t: number, b: number, c: number, d: number) => { + t /= d / 2; + if (t < 1) { + return (c / 2) * t * t + b; + } + t--; + return (-c / 2) * (t * (t - 2) - 1) + b; +}; + +// requestAnimationFrame for Smart Animating http://goo.gl/sx5sts +const requestAnimFrame = (function () { + return ( + window.requestAnimationFrame || + (window as any).webkitRequestAnimationFrame || + (window as any).mozRequestAnimationFrame || + function (callback) { + window.setTimeout(callback, 1000 / 60); + } + ); +})(); + +/** + * Because it's so fucking difficult to detect the scrolling element, just move them all + * @param {number} amount + */ +const move = (amount: number) => { + document.documentElement.scrollTop = amount; + (document.body.parentNode as HTMLElement).scrollTop = amount; + document.body.scrollTop = amount; +}; + +const position = () => { + return ( + document.documentElement.scrollTop || + (document.body.parentNode as HTMLElement).scrollTop || + document.body.scrollTop + ); +}; + +/** + * @param {number} to + * @param {number} duration + * @param {Function} callback + */ +export const scrollTo = (to: number, duration: number, callback?: any) => { + const start = position(); + const change = to - start; + const increment = 20; + let currentTime = 0; + duration = typeof duration === "undefined" ? 500 : duration; + const animateScroll = function () { + // increment the time + currentTime += increment; + // find the value with the quadratic in-out easing function + const val = easeInOutQuad(currentTime, start, change, duration); + // move the document.body + move(val); + // do the animation unless its over + if (currentTime < duration) { + requestAnimFrame(animateScroll); + } else { + if (callback && typeof callback === "function") { + // the animation is done so lets callback + callback(); + } + } + }; + animateScroll(); +}; diff --git a/apps/frontend/src/utils/time.ts b/apps/frontend/src/utils/time.ts new file mode 100644 index 0000000..f950375 --- /dev/null +++ b/apps/frontend/src/utils/time.ts @@ -0,0 +1,101 @@ +/** + * 将时间戳转换为格式化日期时间字符串(YYYY-MM-DD HH:mm:ss) + * @param timestamp 时间戳 + * @returns 格式化日期时间字符串 + */ +export const timestampToDateTime = (timestamp: number): string => { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + const seconds = date.getSeconds().toString().padStart(2, "0"); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +}; + +export const calculateTimeDifference = (timestamp: number): string => { + const now = Date.now(); + const diff = timestamp - now; + + if (diff <= 0) { + return "-"; + } + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + const remainingHours = hours % 24; + const remainingMinutes = minutes % 60; + const remainingSeconds = seconds % 60; + + const parts: string[] = []; + + if (days > 0) { + parts.push(`${days}天`); + } + if (remainingHours > 0) { + parts.push(`${remainingHours}小时`); + } + if (remainingMinutes > 0) { + parts.push(`${remainingMinutes}分钟`); + } + if (remainingSeconds > 0) { + parts.push(`${remainingSeconds}秒`); + } + + return parts.join(" "); +}; + +/** + * 获取一小时后的时间戳 + * @returns 一周后的时间戳 + */ +export const getHourLater = (): number => { + const date = new Date(); + date.setHours(date.getHours() + 1); + return date.getTime(); +}; + +/** + * 获取一天后的时间戳 + * @returns 一周后的时间戳 + */ +export const getDayLater = (): number => { + const date = new Date(); + date.setDate(date.getDate() + 1); + return date.getTime(); +}; + +/** + * 获取一周后的时间戳 + * @returns 一周后的时间戳 + */ +export const getWeekLater = (): number => { + const date = new Date(); + date.setDate(date.getDate() + 7); + return date.getTime(); +}; + +/** + * 获取一个月后的时间戳 + * @returns 一个月后的时间戳 + */ +export const getMonthLater = (): number => { + const date = new Date(); + date.setMonth(date.getMonth() + 1); + return date.getTime(); +}; + +/** + * 获取一年后的时间戳 + * @returns 一年后的时间戳 + */ +export const getYearLater = (): number => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 1); + return date.getTime(); +}; diff --git a/apps/frontend/src/views/account/list/index.vue b/apps/frontend/src/views/account/list/index.vue new file mode 100644 index 0000000..975b2e4 --- /dev/null +++ b/apps/frontend/src/views/account/list/index.vue @@ -0,0 +1,975 @@ + + + + + diff --git a/apps/frontend/src/views/config/list/index.vue b/apps/frontend/src/views/config/list/index.vue new file mode 100644 index 0000000..d0acf4c --- /dev/null +++ b/apps/frontend/src/views/config/list/index.vue @@ -0,0 +1,486 @@ + + + + + + + diff --git a/apps/frontend/src/views/error-page/401.vue b/apps/frontend/src/views/error-page/401.vue new file mode 100644 index 0000000..f1afe6e --- /dev/null +++ b/apps/frontend/src/views/error-page/401.vue @@ -0,0 +1,97 @@ + + + + + + + + diff --git a/apps/frontend/src/views/error-page/404.vue b/apps/frontend/src/views/error-page/404.vue new file mode 100644 index 0000000..6dae502 --- /dev/null +++ b/apps/frontend/src/views/error-page/404.vue @@ -0,0 +1,271 @@ + + + + + + + + diff --git a/apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue b/apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue new file mode 100644 index 0000000..8e1999f --- /dev/null +++ b/apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue @@ -0,0 +1,379 @@ + + + + + + + diff --git a/apps/frontend/src/views/hysteria/list/index.vue b/apps/frontend/src/views/hysteria/list/index.vue new file mode 100644 index 0000000..f4fcda8 --- /dev/null +++ b/apps/frontend/src/views/hysteria/list/index.vue @@ -0,0 +1,1626 @@ + + + + + + + diff --git a/apps/frontend/src/views/info/account/index.vue b/apps/frontend/src/views/info/account/index.vue new file mode 100644 index 0000000..f2ed549 --- /dev/null +++ b/apps/frontend/src/views/info/account/index.vue @@ -0,0 +1,339 @@ + + + + + + + diff --git a/apps/frontend/src/views/log/hysteria/index.vue b/apps/frontend/src/views/log/hysteria/index.vue new file mode 100644 index 0000000..1885cc1 --- /dev/null +++ b/apps/frontend/src/views/log/hysteria/index.vue @@ -0,0 +1,111 @@ + + + + + + + diff --git a/apps/frontend/src/views/log/system/index.vue b/apps/frontend/src/views/log/system/index.vue new file mode 100644 index 0000000..0ddd70d --- /dev/null +++ b/apps/frontend/src/views/log/system/index.vue @@ -0,0 +1,111 @@ + + + + + + + diff --git a/apps/frontend/src/views/login/index.vue b/apps/frontend/src/views/login/index.vue new file mode 100644 index 0000000..e5a46df --- /dev/null +++ b/apps/frontend/src/views/login/index.vue @@ -0,0 +1,240 @@ + + + + + + + diff --git a/apps/frontend/src/views/monitor/system/index.vue b/apps/frontend/src/views/monitor/system/index.vue new file mode 100644 index 0000000..453eacd --- /dev/null +++ b/apps/frontend/src/views/monitor/system/index.vue @@ -0,0 +1,222 @@ + + + + + + + diff --git a/apps/frontend/src/views/redirect/index.vue b/apps/frontend/src/views/redirect/index.vue new file mode 100644 index 0000000..2b61386 --- /dev/null +++ b/apps/frontend/src/views/redirect/index.vue @@ -0,0 +1,15 @@ + + + diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json new file mode 100644 index 0000000..c4776f4 --- /dev/null +++ b/apps/frontend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "esnext", + "useDefineForClassFields": true, + "module": "esnext", + "moduleResolution": "node", + "strict": true, + "jsx": "preserve", + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "lib": ["esnext", "dom"], + "baseUrl": ".", + "allowJs": true, + "paths": { + "@/*": ["src/*"] + }, + "types": ["vite/client", "element-plus/global", "unplugin-icons/types/vue"], + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "allowSyntheticDefaultImports": true /* 允许默认导入 */, + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */ + }, + "include": [ + "src/**/*.ts", + "src/**/*.vue", + "src/types/**/*.d.ts", + "types/index.d.ts" + ], + "exclude": ["node_modules", "dist", "**/*.js"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/frontend/tsconfig.node.json b/apps/frontend/tsconfig.node.json new file mode 100644 index 0000000..9d31e2a --- /dev/null +++ b/apps/frontend/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/frontend/types/index.d.ts b/apps/frontend/types/index.d.ts new file mode 100644 index 0000000..cb7e5c9 --- /dev/null +++ b/apps/frontend/types/index.d.ts @@ -0,0 +1,11 @@ +declare type DialogType = { + title: string; + visible: boolean; +}; + +declare type OptionType = { + value: string; + label: string; + checked?: boolean; + children?: OptionType[]; +}; diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts new file mode 100644 index 0000000..baa902e --- /dev/null +++ b/apps/frontend/vite.config.ts @@ -0,0 +1,126 @@ +import vue from "@vitejs/plugin-vue"; + +import { ConfigEnv, defineConfig, loadEnv, UserConfig } from "vite"; + +import AutoImport from "unplugin-auto-import/vite"; +import Components from "unplugin-vue-components/vite"; +import { ElementPlusResolver } from "unplugin-vue-components/resolvers"; + +import Icons from "unplugin-icons/vite"; +import IconsResolver from "unplugin-icons/resolver"; + +import { createSvgIconsPlugin } from "vite-plugin-svg-icons"; + +import UnoCSS from "unocss/vite"; + +import path from "path"; + +const pathSrc = path.resolve(__dirname, "src"); + +// eslint-disable-next-line no-control-regex +const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g; +const DRIVE_LETTER_REGEX = /^[a-z]:/i; + +import { dynamicBase } from "vite-plugin-dynamic-base"; + +export default defineConfig(({ mode }: ConfigEnv): UserConfig => { + const env = loadEnv(mode, process.cwd()); + return { + base: mode === "production" ? "/__dynamic_base__/" : "/", + resolve: { + alias: { + "@": pathSrc, + }, + }, + css: { + // CSS 预处理器 + preprocessorOptions: { + //define global scss variable + scss: { + javascriptEnabled: true, + additionalData: ` + @use "@/styles/variables.scss" as *; + `, + }, + }, + }, + server: { + host: "0.0.0.0", + port: Number(env.VITE_APP_PORT), + open: true, // 运行是否自动打开浏览器 + proxy: { + // 反向代理解决跨域 + [env.VITE_APP_BASE_API]: { + target: "http://127.0.0.1:8081", + changeOrigin: true, + }, + }, + }, + plugins: [ + dynamicBase({}), + vue(), + UnoCSS({ + /* options */ + }), + AutoImport({ + // 自动导入 Vue 相关函数,如:ref, reactive, toRef 等 + imports: ["vue", "@vueuse/core"], + eslintrc: { + enabled: false, // Default `false` + filepath: "./.eslintrc-auto-import.json", // Default `./.eslintrc-auto-import.json` + globalsPropValue: true, // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable') + }, + resolvers: [ + // 自动导入 Element Plus 相关函数,如:ElMessage, ElMessageBox... (带样式) + ElementPlusResolver(), + // 自动导入图标组件 + IconsResolver({}), + ], + vueTemplate: true, // 是否在 vue 模板中自动导入 + dts: path.resolve(pathSrc, "types", "auto-imports.d.ts"), // 自动导入组件类型声明文件位置,默认根目录; false 关闭自动生成 + }), + + Components({ + resolvers: [ + // 自动注册图标组件 + IconsResolver({ + enabledCollections: ["ep"], //@iconify-json/ep 是 Element Plus 的图标库 + }), + // 自动导入 Element Plus 组件 + ElementPlusResolver(), + ], + dts: path.resolve(pathSrc, "types", "components.d.ts"), // 自动导入组件类型声明文件位置,默认根目录; false 关闭自动生成 + }), + + Icons({ + // 自动安装图标库 + autoInstall: true, + }), + + createSvgIconsPlugin({ + // 指定需要缓存的图标文件夹 + iconDirs: [path.resolve(pathSrc, "assets/icons")], + // 指定symbolId格式 + symbolId: "icon-[dir]-[name]", + }), + ], + build: { + rollupOptions: { + output: { + sanitizeFileName(name: string): string { + // https://github.com/rollup/rollup/blob/master/src/utils/sanitizeFileName.ts + const match = DRIVE_LETTER_REGEX.exec(name); + const driveLetter = match ? match[0] : ""; + + // A `:` is only allowed as part of a windows drive letter (ex: C:\foo) + // Otherwise, avoid them because they can refer to NTFS alternate data streams. + return ( + driveLetter + + name.slice(driveLetter.length).replace(INVALID_CHAR_REGEX, "") + ); + }, + }, + }, + }, + }; +}); diff --git a/apps/go.mod b/apps/go.mod new file mode 100644 index 0000000..03342e5 --- /dev/null +++ b/apps/go.mod @@ -0,0 +1,69 @@ +module hy2xs-admin + +go 1.21 + +require ( + github.com/didip/tollbooth v4.0.2+incompatible + github.com/gin-gonic/gin v1.9.1 + github.com/glebarez/sqlite v1.11.0 + github.com/go-playground/validator/v10 v10.14.0 + 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/shirou/gopsutil v3.21.11+incompatible + github.com/sirupsen/logrus v1.9.3 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/spf13/cobra v1.8.1 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/gorm v1.25.9 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.2 // 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/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/tklauser/go-sysconf v0.3.13 // indirect + github.com/tklauser/numcpus v0.7.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // 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/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/apps/go.sum b/apps/go.sum new file mode 100644 index 0000000..ade1758 --- /dev/null +++ b/apps/go.sum @@ -0,0 +1,182 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/didip/tollbooth v4.0.2+incompatible h1:fVSa33JzSz0hoh2NxpwZtksAzAgd7zjmGO20HCZtF4M= +github.com/didip/tollbooth v4.0.2+incompatible/go.mod h1:A9b0665CE6l1KmzpDws2++elm/CsuWBMa5Jv4WY0PEY= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +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/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/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/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.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/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/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/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4= +github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0= +github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4= +github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +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/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/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/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-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-20220715151400-c0bba94af5f8/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/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/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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/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= +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.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8= +gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/apps/main.go b/apps/main.go new file mode 100644 index 0000000..e87ba4a --- /dev/null +++ b/apps/main.go @@ -0,0 +1,7 @@ +package main + +import "hy2xs-admin/cmd" + +func main() { + cmd.Execute() +} diff --git a/apps/middleware/admin.go b/apps/middleware/admin.go new file mode 100644 index 0000000..27ce1af --- /dev/null +++ b/apps/middleware/admin.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/vo" + "hy2xs-admin/service" + "hy2xs-admin/util" +) + +func AdminHandler() gin.HandlerFunc { + return func(c *gin.Context) { + myClaims, err := service.ParseToken(service.GetToken(c)) + if err != nil { + vo.Fail(err.Error(), c) + c.Abort() + return + } + if !util.ArrContain(myClaims.AccountBo.Roles, "admin") { + vo.Fail(constant.ForbiddenError, c) + c.Abort() + return + } + c.Next() + } +} diff --git a/apps/middleware/cron.go b/apps/middleware/cron.go new file mode 100644 index 0000000..84297a7 --- /dev/null +++ b/apps/middleware/cron.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "errors" + "github.com/robfig/cron/v3" + "github.com/sirupsen/logrus" + "hy2xs-admin/dao" + "hy2xs-admin/model/constant" + "hy2xs-admin/service" + "time" +) + +func InitCron() error { + loc := time.Now().Location() + c := cron.New(cron.WithLocation(loc)) + _, err := c.AddFunc("@every 30s", service.CronHandleAccount) + if err != nil { + logrus.Errorf("cron add func CronHandleAccount err: %v", err) + return errors.New("cron add func CronHandleAccount err") + } + resetTrafficCron, err := dao.GetConfig("key = ?", constant.ResetTrafficCron) + if err != nil { + return err + } + if *resetTrafficCron.Value != "" { + _, err := c.AddFunc(*resetTrafficCron.Value, service.CronResetTraffic) + if err != nil { + logrus.Errorf("cron add func CronResetTraffic err: %v", err) + } + } + c.Start() + return nil +} diff --git a/apps/middleware/filter.go b/apps/middleware/filter.go new file mode 100644 index 0000000..2cc7a51 --- /dev/null +++ b/apps/middleware/filter.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/model/vo" + "net/http" + "regexp" +) + +func FilterHandler() gin.HandlerFunc { + return func(c *gin.Context) { + matched, err := regexp.MatchString(`(?i)fofa|shodan|curl|wget`, c.Request.UserAgent()) + if err != nil { + vo.Fail("Internal error", c) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + if matched { + vo.Fail("Forbidden: Scanning tools are not allowed", c) + c.AbortWithStatus(http.StatusForbidden) + return + } + c.Next() + } +} diff --git a/apps/middleware/jwt.go b/apps/middleware/jwt.go new file mode 100644 index 0000000..0047d70 --- /dev/null +++ b/apps/middleware/jwt.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/vo" + "hy2xs-admin/service" + "strings" +) + +func JWTHandler() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.Request.Header.Get("Authorization") + if authHeader == "" { + vo.Fail(constant.UnauthorizedError, c) + c.Abort() + return + } + parts := strings.SplitN(authHeader, " ", 2) + if !(len(parts) == 2 && parts[0] == "Bearer") { + vo.Fail(constant.IllegalTokenError, c) + c.Abort() + return + } + myClaims, err := service.ParseToken(parts[1]) + if err != nil { + vo.Fail(err.Error(), c) + c.Abort() + return + } + if myClaims.AccountBo.Deleted != 0 { + vo.Fail("this account has been disabled", c) + c.Abort() + return + } + c.Next() + } +} diff --git a/apps/middleware/log.go b/apps/middleware/log.go new file mode 100644 index 0000000..a4b385a --- /dev/null +++ b/apps/middleware/log.go @@ -0,0 +1,43 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "gopkg.in/natefinch/lumberjack.v2" + "hy2xs-admin/model/constant" + "time" +) + +func InitLog() { + logrus.SetOutput(&lumberjack.Logger{ + Filename: constant.SystemLogPath, + MaxSize: 1, + MaxBackups: 2, + MaxAge: 30, + Compress: true, + LocalTime: true, + }) + logrus.SetFormatter(&logrus.JSONFormatter{TimestampFormat: "2006-01-02 15:04:05"}) + logrus.SetLevel(logrus.WarnLevel) +} + +func LogHandler() gin.HandlerFunc { + return func(c *gin.Context) { + startTime := time.Now() + endTime := time.Now() + statusCode := c.Writer.Status() + latencyTime := endTime.Sub(startTime) + clientIP := c.ClientIP() + reqMethod := c.Request.Method + reqUri := c.Request.RequestURI + + logrus.WithFields(logrus.Fields{ + "statusCode": statusCode, + "latencyTime": latencyTime, + "clientIP": clientIP, + "reqMethod": reqMethod, + "reqUri": reqUri, + }).Info() + c.Next() + } +} diff --git a/apps/middleware/rate_limiter.go b/apps/middleware/rate_limiter.go new file mode 100644 index 0000000..eeb7c77 --- /dev/null +++ b/apps/middleware/rate_limiter.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "github.com/didip/tollbooth" + "github.com/didip/tollbooth/limiter" + "github.com/gin-gonic/gin" + "hy2xs-admin/model/vo" +) + +var limit *limiter.Limiter + +func init() { + limit = tollbooth.NewLimiter(5, nil) +} + +func RateLimiterHandler() gin.HandlerFunc { + return func(c *gin.Context) { + httpError := tollbooth.LimitByRequest(limit, c.Writer, c.Request) + if httpError != nil { + vo.Fail("click too fast", c) + c.Abort() + return + } + c.Next() + } +} diff --git a/apps/model/bo/account.go b/apps/model/bo/account.go new file mode 100644 index 0000000..b04b911 --- /dev/null +++ b/apps/model/bo/account.go @@ -0,0 +1,30 @@ +package bo + +import "time" + +type AccountBo struct { + Id int64 `json:"id"` + Username string `json:"username"` + Roles []string `json:"roles"` + Deleted int64 `json:"deleted"` +} + +type AccountExport struct { + Id int64 `json:"id"` + Username string `json:"username"` + Pass string `json:"pass"` + ConPass string `json:"conPass"` + Quota int64 `json:"quota"` + Download int64 `json:"download"` + Upload int64 `json:"upload"` + ExpireTime int64 `json:"expireTime"` + DeviceNo int64 `json:"deviceNo"` + KickUtilTime int64 `json:"kickUtilTime"` + Role string `json:"role"` + Deleted int64 `json:"deleted"` + CreateTime time.Time `json:"createTime"` + UpdateTime time.Time `json:"updateTime"` + LoginAt int64 `json:"loginAt"` + ConAt int64 `json:"conAt"` + Remark string `json:"remark"` +} diff --git a/apps/model/bo/hysteria2.go b/apps/model/bo/hysteria2.go new file mode 100644 index 0000000..9005662 --- /dev/null +++ b/apps/model/bo/hysteria2.go @@ -0,0 +1,205 @@ +package bo + +type Hysteria2ServerConfig struct { + Listen *string `yaml:"listen,omitempty" json:"listen" validate:"required"` + Obfs *serverConfigObfs `yaml:"obfs,omitempty" json:"obfs" validate:"omitempty"` + TLS *serverConfigTLS `yaml:"tls,omitempty" json:"tls" validate:"omitempty"` + ACME *serverConfigACME `yaml:"acme,omitempty" json:"acme" validate:"omitempty"` + QUIC *serverConfigQUIC `yaml:"quic,omitempty" json:"quic" validate:"omitempty"` + Bandwidth *serverConfigBandwidth `yaml:"bandwidth,omitempty" json:"bandwidth" validate:"omitempty"` + IgnoreClientBandwidth *bool `yaml:"ignoreClientBandwidth,omitempty" json:"ignoreClientBandwidth" validate:"omitempty"` + SpeedTest *bool `yaml:"speedTest,omitempty" json:"speedTest" validate:"omitempty"` + DisableUDP *bool `yaml:"disableUDP,omitempty" json:"disableUDP" validate:"omitempty"` + UDPIdleTimeout *string `yaml:"udpIdleTimeout,omitempty" json:"udpIdleTimeout" validate:"omitempty"` + Auth *ServerConfigAuth `yaml:"auth,omitempty" json:"-" validate:"omitempty"` + Resolver *serverConfigResolver `yaml:"resolver,omitempty" json:"resolver" validate:"omitempty"` + Sniff *serverConfigSniff `yaml:"sniff,omitempty" json:"sniff" validate:"omitempty"` + ACL *serverConfigACL `yaml:"acl,omitempty" json:"acl" validate:"omitempty"` + Outbounds []serverConfigOutboundEntry `yaml:"outbounds,omitempty" json:"outbounds" validate:"omitempty"` + TrafficStats *ServerConfigTrafficStats `yaml:"trafficStats,omitempty" json:"trafficStats" validate:"required"` + Masquerade *serverConfigMasquerade `yaml:"masquerade,omitempty" json:"masquerade" validate:"omitempty"` +} + +type serverConfigObfsSalamander struct { + Password *string `yaml:"password,omitempty" json:"password" validate:"required"` +} + +type serverConfigObfs struct { + Type *string `yaml:"type,omitempty" json:"type" validate:"required"` + Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"required"` +} + +type serverConfigTLS struct { + Cert *string `yaml:"cert,omitempty" json:"cert" validate:"required"` + Key *string `yaml:"key,omitempty" json:"key" validate:"required"` + SNIGuard *string `yaml:"sniGuard,omitempty" json:"sniGuard" validate:"omitempty"` +} + +type serverConfigACME struct { + // Common fields + Domains []string `yaml:"domains,omitempty" json:"domains" validate:"required"` + Email *string `yaml:"email,omitempty" json:"email" validate:"required"` + CA *string `yaml:"ca,omitempty" json:"ca" validate:"required"` + ListenHost *string `yaml:"listenHost,omitempty" json:"listenHost" validate:"required"` + Dir *string `yaml:"dir,omitempty" json:"dir" validate:"required"` + + // Type selection + Type *string `yaml:"type,omitempty" json:"type" validate:"omitempty"` + HTTP *serverConfigACMEHTTP `yaml:"http,omitempty" json:"http" validate:"omitempty"` + TLS *serverConfigACMETLS `yaml:"tls,omitempty" json:"tls" validate:"omitempty"` + DNS *serverConfigACMEDNS `yaml:"dns,omitempty" json:"dns" validate:"omitempty"` + + // Legacy fields for backwards compatibility + // Only applicable when Type is empty + DisableHTTP *bool `yaml:"disableHTTP,omitempty" json:"disableHTTP" validate:"required"` + DisableTLSALPN *bool `yaml:"disableTLSALPN,omitempty" json:"disableTLSALPN" validate:"required"` + AltHTTPPort *int `yaml:"altHTTPPort,omitempty" json:"altHTTPPort" validate:"required"` + AltTLSALPNPort *int `yaml:"altTLSALPNPort,omitempty" json:"altTLSALPNPort" validate:"required"` +} + +type serverConfigACMEHTTP struct { + AltPort *int `yaml:"altPort,omitempty" json:"altPort" validate:"required"` +} + +type serverConfigACMETLS struct { + AltPort *int `yaml:"altPort,omitempty" json:"altPort" validate:"required"` +} + +type serverConfigACMEDNS struct { + Name *string `yaml:"name,omitempty" json:"name" validate:"required"` + Config map[string]string `yaml:"config,omitempty" json:"config" validate:"required"` +} + +type serverConfigQUIC struct { + InitStreamReceiveWindow *uint64 `yaml:"initStreamReceiveWindow,omitempty" json:"initStreamReceiveWindow" validate:"omitempty"` + MaxStreamReceiveWindow *uint64 `yaml:"maxStreamReceiveWindow,omitempty" json:"maxStreamReceiveWindow" validate:"omitempty"` + InitConnectionReceiveWindow *uint64 `yaml:"initConnReceiveWindow,omitempty" json:"initConnReceiveWindow" validate:"omitempty"` + MaxConnectionReceiveWindow *uint64 `yaml:"maxConnReceiveWindow,omitempty" json:"maxConnReceiveWindow" validate:"omitempty"` + MaxIdleTimeout *string `yaml:"maxIdleTimeout,omitempty" json:"maxIdleTimeout" validate:"omitempty"` + MaxIncomingStreams *int64 `yaml:"maxIncomingStreams,omitempty" json:"maxIncomingStreams" validate:"omitempty"` + DisablePathMTUDiscovery *bool `yaml:"disablePathMTUDiscovery,omitempty" json:"disablePathMTUDiscovery" validate:"omitempty"` +} + +type serverConfigBandwidth struct { + Up *string `yaml:"up,omitempty" json:"up" validate:"required"` + Down *string `yaml:"down,omitempty" json:"down" validate:"required"` +} + +type ServerConfigAuthHTTP struct { + URL *string `yaml:"url,omitempty" json:"url" validate:"required"` + Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"` +} + +type ServerConfigAuth struct { + Type *string `yaml:"type,omitempty" json:"type" validate:"omitempty"` + Password *string `yaml:"password,omitempty" json:"password" validate:"omitempty"` + UserPass map[string]string `yaml:"userpass,omitempty" json:"userpass" validate:"omitempty"` + HTTP *ServerConfigAuthHTTP `yaml:"http,omitempty" json:"http" validate:"omitempty"` + Command *string `yaml:"command,omitempty" json:"command" validate:"omitempty"` +} + +type serverConfigResolverTCP struct { + Addr *string `yaml:"addr,omitempty" json:"addr" validate:"required"` + Timeout *string `yaml:"timeout,omitempty" json:"timeout" validate:"required"` +} + +type serverConfigResolverUDP struct { + Addr *string `yaml:"addr,omitempty" json:"addr" validate:"required"` + Timeout *string `yaml:"timeout,omitempty" json:"timeout" validate:"required"` +} + +type serverConfigResolverTLS struct { + Addr *string `yaml:"addr,omitempty" json:"addr" validate:"required"` + Timeout *string `yaml:"timeout,omitempty" json:"timeout" validate:"required"` + SNI *string `yaml:"sni,omitempty" json:"sni" validate:"required"` + Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"` +} + +type serverConfigResolverHTTPS struct { + Addr *string `yaml:"addr,omitempty" json:"addr" validate:"required"` + Timeout *string `yaml:"timeout,omitempty" json:"timeout" validate:"required"` + SNI *string `yaml:"sni,omitempty" json:"sni" validate:"required"` + Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"` +} + +type serverConfigResolver struct { + Type *string `yaml:"type,omitempty" json:"type" validate:"omitempty"` + TCP *serverConfigResolverTCP `yaml:"tcp,omitempty" json:"tcp" validate:"omitempty"` + UDP *serverConfigResolverUDP `yaml:"udp,omitempty" json:"udp" validate:"omitempty"` + TLS *serverConfigResolverTLS `yaml:"tls,omitempty" json:"tls" validate:"omitempty"` + HTTPS *serverConfigResolverHTTPS `yaml:"https,omitempty" json:"https" validate:"omitempty"` +} + +type serverConfigSniff struct { + Enable *bool `yaml:"enable,omitempty" json:"enable" validate:"required"` + Timeout *string `yaml:"timeout,omitempty" json:"timeout" validate:"required"` + RewriteDomain *bool `yaml:"rewriteDomain,omitempty" json:"rewriteDomain" validate:"required"` + TCPPorts *string `yaml:"tcpPorts,omitempty" json:"tcpPorts" validate:"omitempty"` + UDPPorts *string `yaml:"udpPorts,omitempty" json:"udpPorts" validate:"omitempty"` +} + +type serverConfigACL struct { + File *string `yaml:"file,omitempty" json:"file" validate:"omitempty"` + Inline []string `yaml:"inline,omitempty" json:"inline" validate:"omitempty"` + GeoIP *string `yaml:"geoip,omitempty" json:"geoip" validate:"omitempty"` + GeoSite *string `yaml:"geosite,omitempty" json:"geosite" validate:"omitempty"` + GeoUpdateInterval *string `yaml:"geoUpdateInterval,omitempty" json:"geoUpdateInterval" validate:"omitempty"` +} + +type serverConfigOutboundDirect struct { + Mode *string `yaml:"mode,omitempty" json:"mode" validate:"required"` + BindIPv4 *string `yaml:"bindIPv4,omitempty" json:"bindIPv4" validate:"required"` + BindIPv6 *string `yaml:"bindIPv6,omitempty" json:"bindIPv6" validate:"required"` + BindDevice *string `yaml:"bindDevice,omitempty" json:"bindDevice" validate:"required"` + FastOpen *bool `yaml:"fastOpen,omitempty" json:"fastOpen" validate:"required"` +} + +type serverConfigOutboundSOCKS5 struct { + Addr *string `yaml:"addr,omitempty" json:"addr" validate:"required"` + Username *string `yaml:"username,omitempty" json:"username" validate:"omitempty"` + Password *string `yaml:"password,omitempty" json:"password" validate:"omitempty"` +} + +type serverConfigOutboundHTTP struct { + URL *string `yaml:"url,omitempty" json:"url" validate:"required"` + Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"` +} + +type serverConfigOutboundEntry struct { + Name *string `yaml:"name,omitempty" json:"name" validate:"required"` + Type *string `yaml:"type,omitempty" json:"type" validate:"omitempty"` + Direct *serverConfigOutboundDirect `yaml:"direct,omitempty" json:"direct" validate:"omitempty"` + SOCKS5 *serverConfigOutboundSOCKS5 `yaml:"socks5,omitempty" json:"socks5" validate:"omitempty"` + HTTP *serverConfigOutboundHTTP `yaml:"http,omitempty" json:"http" validate:"omitempty"` +} + +type ServerConfigTrafficStats struct { + Listen *string `yaml:"listen,omitempty" json:"listen" validate:"required"` + Secret *string `yaml:"secret,omitempty" json:"-" validate:"omitempty"` +} + +type serverConfigMasqueradeFile struct { + Dir *string `yaml:"dir,omitempty" json:"dir" validate:"required"` +} + +type serverConfigMasqueradeProxy struct { + URL *string `yaml:"url,omitempty" json:"url" validate:"required"` + RewriteHost *bool `yaml:"rewriteHost,omitempty" json:"rewriteHost" validate:"required"` + Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"` +} + +type serverConfigMasqueradeString struct { + Content *string `yaml:"content,omitempty" json:"content" validate:"required"` + Headers map[string]string `yaml:"headers,omitempty" json:"headers" validate:"omitempty"` + StatusCode *int `yaml:"statusCode,omitempty" json:"statusCode" validate:"omitempty"` +} + +type serverConfigMasquerade struct { + Type *string `yaml:"type,omitempty" json:"type" validate:"omitempty"` + File *serverConfigMasqueradeFile `yaml:"file,omitempty" json:"file" validate:"omitempty"` + Proxy *serverConfigMasqueradeProxy `yaml:"proxy,omitempty" json:"proxy" validate:"omitempty"` + String *serverConfigMasqueradeString `yaml:"string,omitempty" json:"string" validate:"omitempty"` + ListenHTTP *string `yaml:"listenHTTP,omitempty" json:"listenHTTP" validate:"omitempty"` + ListenHTTPS *string `yaml:"listenHTTPS,omitempty" json:"listenHTTPS" validate:"omitempty"` + ForceHTTPS *bool `yaml:"forceHTTPS,omitempty" json:"forceHTTPS" validate:"omitempty"` +} diff --git a/apps/model/bo/hysteria2_api.go b/apps/model/bo/hysteria2_api.go new file mode 100644 index 0000000..6bfc578 --- /dev/null +++ b/apps/model/bo/hysteria2_api.go @@ -0,0 +1,6 @@ +package bo + +type Hysteria2UserTraffic struct { + Tx int64 `json:"tx"` // upload + Rx int64 `json:"rx"` // download +} diff --git a/apps/model/bo/subscribe.go b/apps/model/bo/subscribe.go new file mode 100644 index 0000000..7b286d5 --- /dev/null +++ b/apps/model/bo/subscribe.go @@ -0,0 +1,27 @@ +package bo + +type Hysteria2 struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Server string `yaml:"server"` + Port string `yaml:"port"` + Ports string `yaml:"ports,omitempty"` + Password string `yaml:"password"` + Up string `yaml:"up,omitempty"` + Down string `yaml:"down,omitempty"` + Obfs string `yaml:"obfs,omitempty"` + ObfsPassword string `yaml:"obfs-password,omitempty"` + Sni string `yaml:"sni,omitempty"` + SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` +} + +type ProxyGroup struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Proxies []string `yaml:"proxies"` +} + +type ClashConfig struct { + Proxies []interface{} `yaml:"proxies"` + ProxyGroups []ProxyGroup `yaml:"proxy-groups"` +} diff --git a/apps/model/constant/client.go b/apps/model/constant/client.go new file mode 100644 index 0000000..a7b3697 --- /dev/null +++ b/apps/model/constant/client.go @@ -0,0 +1,8 @@ +package constant + +const ( + Shadowrocket = "shadowrocket" + Clash = "clash" + V2rayN = "v2rayn" + NekoBox = "nekobox" +) diff --git a/apps/model/constant/code.go b/apps/model/constant/code.go new file mode 100644 index 0000000..e42e6a4 --- /dev/null +++ b/apps/model/constant/code.go @@ -0,0 +1,9 @@ +package constant + +const ( + CodeSuccess int = 20000 + CodeSysError int = 50000 + CodeUnauthorizedError int = 50401 + CodeForbiddenError int = 50403 + CodeInvalidError int = 50001 +) diff --git a/apps/model/constant/config.go b/apps/model/constant/config.go new file mode 100644 index 0000000..5e2f4c3 --- /dev/null +++ b/apps/model/constant/config.go @@ -0,0 +1,16 @@ +package constant + +const ( + HUIWebPort = "H_UI_WEB_PORT" + HUIWebContext = "H_UI_WEB_CONTEXT" + HUICrtPath = "H_UI_CRT_PATH" + HUIKeyPath = "H_UI_KEY_PATH" + JwtSecret = "JWT_SECRET" + Hysteria2Enable = "HYSTERIA2_ENABLE" + Hysteria2Config = "HYSTERIA2_CONFIG" + Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME" + Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK" + Hysteria2ConfigPortHopping = "HYSTERIA2_CONFIG_PORT_HOPPING" + ResetTrafficCron = "RESET_TRAFFIC_CRON" + ClashExtension = "CLASH_EXTENSION" +) diff --git a/apps/model/constant/error.go b/apps/model/constant/error.go new file mode 100644 index 0000000..468de5e --- /dev/null +++ b/apps/model/constant/error.go @@ -0,0 +1,14 @@ +package constant + +const ( + SysError string = "system error" + UnauthorizedError string = "unauthorized" + ForbiddenError string = "permission denied" + InvalidError string = "invalid" + + IllegalTokenError string = "authentication failed" + TokenExpiredError string = "token expired" + + WrongPassword string = "wrong password" + ConfigNotExist string = "config not exist" +) diff --git a/apps/model/constant/system.go b/apps/model/constant/system.go new file mode 100644 index 0000000..f41ac13 --- /dev/null +++ b/apps/model/constant/system.go @@ -0,0 +1,20 @@ +package constant + +const ( + LogDir = "logs/" + SqliteDBDir = "data/" + BinDir = "bin/" + ExportPathDir = "export/" + + SqliteDBPath = "data/h_ui.db" + + Hysteria2ConfigPath = "/etc/hysteria/config.yaml" + Hysteria2BinPath = "/usr/local/bin/hysteria" + + SystemLogPath = "logs/hy2xs-admin.log" + Hysteria2LogPath = "logs/hysteria2.log" + + TokenType = "Bearer" + + Version = "v0.0.22" +) diff --git a/apps/model/dto/account.go b/apps/model/dto/account.go new file mode 100644 index 0000000..50b2915 --- /dev/null +++ b/apps/model/dto/account.go @@ -0,0 +1,36 @@ +package dto + +type AccountPageDto struct { + BaseDto + Username *string `json:"username" form:"username" validate:"omitempty,min=1,max=32"` + Deleted *int64 `json:"deleted" form:"deleted" validate:"omitempty,oneof=0 1"` + Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=32"` +} + +type LoginDto struct { + Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"` + Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=32,validateStr"` +} + +type AccountSaveDto struct { + Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"` + Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=32,validateStr"` + ConPass *string `json:"conPass" form:"conPass" validate:"required,min=6,max=32,validateStr"` + Quota *int64 `json:"quota" form:"quota" validate:"required,min=-1"` + ExpireTime *int64 `json:"expireTime" form:"expireTime" validate:"required,min=0"` + DeviceNo *int64 `json:"deviceNo" form:"deviceNo" validate:"required,min=1"` + Deleted *int64 `json:"deleted" form:"deleted" validate:"required,oneof=0 1"` + Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=32"` +} + +type AccountUpdateDto struct { + IdDto + Username *string `json:"username" form:"username" validate:"omitempty,min=6,max=32,validateStr"` + Pass *string `json:"pass" form:"pass" validate:"omitempty,min=6,max=32,validateStr"` + ConPass *string `json:"conPass" form:"conPass" validate:"omitempty,min=6,max=32,validateStr"` + Quota *int64 `json:"quota" form:"quota" validate:"omitempty,min=-1"` + ExpireTime *int64 `json:"expireTime" form:"expireTime" validate:"omitempty,min=0"` + DeviceNo *int64 `json:"deviceNo" form:"deviceNo" validate:"omitempty,min=1"` + Deleted *int64 `json:"deleted" form:"deleted" validate:"omitempty,oneof=0 1"` + Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=32"` +} diff --git a/apps/model/dto/config.go b/apps/model/dto/config.go new file mode 100644 index 0000000..0646770 --- /dev/null +++ b/apps/model/dto/config.go @@ -0,0 +1,18 @@ +package dto + +type ConfigDto struct { + Key *string `json:"key" form:"key" validate:"required,min=1,max=128"` +} + +type ConfigsDto struct { + Keys []string `json:"keys" form:"keys" validate:"required"` +} + +type ConfigUpdateDto struct { + Key *string `json:"key" form:"key" validate:"required,min=1,max=128"` + Value *string `json:"value" form:"value" validate:"required,min=0,max=128"` +} + +type ConfigsUpdateDto struct { + ConfigUpdateDtos []ConfigUpdateDto `json:"configUpdateDtos" form:"configUpdateDtos" validate:"required"` +} diff --git a/apps/model/dto/dto.go b/apps/model/dto/dto.go new file mode 100644 index 0000000..c1f234d --- /dev/null +++ b/apps/model/dto/dto.go @@ -0,0 +1,12 @@ +package dto + +type BaseDto struct { + PageNum *int64 `json:"pageNum" form:"pageNum" validate:"required,gt=0"` // 页号 + PageSize *int64 `json:"pageSize" form:"pageSize" validate:"required,gt=0"` // 页大小 + StartTime *int64 `json:"startTime" form:"startTime" validate:"omitempty,gt=0"` // 开始时间 + EndTime *int64 `json:"endTime" form:"endTime" validate:"omitempty,gt=0"` // 结束时间 +} + +type IdDto struct { + Id *int64 `json:"id" form:"id" validate:"required,gt=0"` // 主键 +} diff --git a/apps/model/dto/hysteria2.go b/apps/model/dto/hysteria2.go new file mode 100644 index 0000000..96592a5 --- /dev/null +++ b/apps/model/dto/hysteria2.go @@ -0,0 +1,27 @@ +package dto + +type Hysteria2AuthDto struct { + Addr *string `json:"addr" form:"addr" validate:"required"` + Auth *string `json:"auth" form:"auth" validate:"required"` + Tx *string `json:"tx" form:"tx" validate:"required"` +} + +type Hysteria2KickDto struct { + Ids []int64 `json:"ids" form:"ids" validate:"required"` + KickUtilTime *int64 `json:"kickUtilTime" form:"kickUtilTime" validate:"required"` // 解禁时间 +} + +type Hysteria2VersionDto struct { + Version *string `json:"version" form:"version" validate:"required,min=1,max=10"` +} + +type Hysteria2SubscribeUrlDto struct { + AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"` + Protocol *string `json:"protocol" form:"protocol" validate:"required,min=1,max=8"` + Host *string `json:"host" form:"host" validate:"required,min=1,max=301"` +} + +type Hysteria2UrlDto struct { + AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"` + Hostname *string `json:"hostname" form:"hostname" validate:"required,min=1,max=255"` +} diff --git a/apps/model/dto/log.go b/apps/model/dto/log.go new file mode 100644 index 0000000..980843e --- /dev/null +++ b/apps/model/dto/log.go @@ -0,0 +1,9 @@ +package dto + +type LogDto struct { + NumLine *int `json:"numLine" form:"numLine" validate:"omitempty,min=1,max=300"` +} + +type LogExportDto struct { + Option *int `json:"option" form:"option" validate:"required,oneof=0 1"` +} diff --git a/apps/model/dto/server.go b/apps/model/dto/server.go new file mode 100644 index 0000000..66b93b8 --- /dev/null +++ b/apps/model/dto/server.go @@ -0,0 +1,5 @@ +package dto + +type ServerDto struct { + Port *int64 `json:"port" form:"port" validate:"required,min=1,max=65535"` +} diff --git a/apps/model/entity/account.go b/apps/model/entity/account.go new file mode 100644 index 0000000..bb98785 --- /dev/null +++ b/apps/model/entity/account.go @@ -0,0 +1,20 @@ +package entity + +type Account struct { + Username *string `gorm:"column:username;default:''" json:"username"` + Pass *string `gorm:"column:pass;default:''" json:"pass"` + ConPass *string `gorm:"column:con_pass;default:''" json:"conPass"` + Quota *int64 `gorm:"column:quota;default:0" json:"quota"` + Download *int64 `gorm:"column:download;default:0" json:"download"` + Upload *int64 `gorm:"column:upload;default:0" json:"upload"` + ExpireTime *int64 `gorm:"column:expire_time;default:0" json:"expireTime"` + KickUtilTime *int64 `gorm:"column:kick_util_time;default:0" json:"kickUtilTime"` + DeviceNo *int64 `gorm:"column:device_no;default:3" json:"deviceNo"` + Role *string `gorm:"column:role;default:'user'" json:"role"` + Deleted *int64 `gorm:"column:deleted;default:0" json:"deleted"` + BaseEntity `gorm:"embedded"` + + LoginAt *int64 `gorm:"column:login_at;default:0" json:"loginAt"` + ConAt *int64 `gorm:"column:con_at;default:0" json:"conAt"` + Remark *string `gorm:"column:remark;default:''" json:"remark"` +} diff --git a/apps/model/entity/config.go b/apps/model/entity/config.go new file mode 100644 index 0000000..e4ee747 --- /dev/null +++ b/apps/model/entity/config.go @@ -0,0 +1,8 @@ +package entity + +type Config struct { + Key *string `gorm:"column:key;default:''" json:"key"` + Value *string `gorm:"column:value;default:''" json:"value"` + Remark *string `gorm:"column:remark;default:''" json:"remark"` + BaseEntity `gorm:"embedded"` +} diff --git a/apps/model/entity/entity.go b/apps/model/entity/entity.go new file mode 100644 index 0000000..897be17 --- /dev/null +++ b/apps/model/entity/entity.go @@ -0,0 +1,9 @@ +package entity + +import "time" + +type BaseEntity struct { + Id *int64 `gorm:"column:id;primaryKey" json:"id"` + CreateTime *time.Time `gorm:"column:create_time;default:null" json:"createTime"` + UpdateTime *time.Time `gorm:"column:update_time;default:null" json:"updateTime"` +} diff --git a/apps/model/vo/account.go b/apps/model/vo/account.go new file mode 100644 index 0000000..415a5e6 --- /dev/null +++ b/apps/model/vo/account.go @@ -0,0 +1,31 @@ +package vo + +type AccountVo struct { + BaseVo + Username string `json:"username"` + Quota int64 `json:"quota"` + Download int64 `json:"download"` + Upload int64 `json:"upload"` + ExpireTime int64 `json:"expireTime"` + KickUtilTime int64 `json:"kickUtilTime"` // Offline remaining time + DeviceNo int64 `json:"deviceNo"` // Limit the number of devices + Role string `json:"role"` + Deleted int64 `json:"deleted"` + + Online bool `json:"online"` // online status + Device int64 `json:"device"` // Number of online devices + + LoginAt int64 `json:"loginAt"` + ConAt int64 `json:"conAt"` + Remark string `json:"remark"` +} +type AccountPageVo struct { + AccountVos []AccountVo `json:"records"` + Total int64 `json:"total"` +} + +type AccountInfoVo struct { + Id int64 `json:"id"` + Username string `json:"username"` + Roles []string `json:"roles"` +} diff --git a/apps/model/vo/config.go b/apps/model/vo/config.go new file mode 100644 index 0000000..3f1019c --- /dev/null +++ b/apps/model/vo/config.go @@ -0,0 +1,7 @@ +package vo + +type ConfigVo struct { + Key string `json:"key"` + Value string `json:"value"` + Remark string `json:"remark"` +} diff --git a/apps/model/vo/hysteria2.go b/apps/model/vo/hysteria2.go new file mode 100644 index 0000000..e37e074 --- /dev/null +++ b/apps/model/vo/hysteria2.go @@ -0,0 +1,40 @@ +package vo + +import ( + "github.com/gin-gonic/gin" + "net/http" +) + +type hysteria2Result struct { + Ok bool `json:"ok"` + Id string `json:"id"` +} + +func Hysteria2AuthSuccess(id string, c *gin.Context) { + c.JSON(http.StatusOK, hysteria2Result{ + Ok: true, + Id: id, + }) +} + +func Hysteria2AuthFail(id string, c *gin.Context) { + c.JSON(http.StatusOK, hysteria2Result{ + Ok: false, + Id: id, + }) +} + +type Hysteria2SubscribeVo struct { + Url string `json:"url"` + QrCode []byte `json:"qrCode"` +} + +type Hysteria2UrlVo struct { + Url string `json:"url"` + QrCode []byte `json:"qrCode"` +} + +type Hysteria2AcmePathVo struct { + CrtPath string `json:"crtPath"` + KeyPath string `json:"keyPath"` +} diff --git a/apps/model/vo/jwt.go b/apps/model/vo/jwt.go new file mode 100644 index 0000000..7b0440a --- /dev/null +++ b/apps/model/vo/jwt.go @@ -0,0 +1,6 @@ +package vo + +type JwtVo struct { + TokenType string `json:"tokenType"` + AccessToken string `json:"accessToken"` +} diff --git a/apps/model/vo/log.go b/apps/model/vo/log.go new file mode 100644 index 0000000..f4fc456 --- /dev/null +++ b/apps/model/vo/log.go @@ -0,0 +1,23 @@ +package vo + +type LogSystemPage[T LogSystemVo | LogHysteria2Vo] struct { + LogSystemVos []T `json:"records"` + Total int64 `json:"total"` +} + +type LogSystemVo struct { + ClientIP string `json:"clientIp"` + LatencyTime int64 `json:"latencyTime"` + Level string `json:"level"` + Msg string `json:"msg"` + ReqMethod string `json:"reqMethod"` + ReqUri string `json:"reqUri"` + StatusCode int64 `json:"statusCode"` + Time string `json:"time"` +} + +type LogHysteria2Vo struct { + Level string `json:"level"` + Msg string `json:"msg"` + Time string `json:"time"` +} diff --git a/apps/model/vo/monitor.go b/apps/model/vo/monitor.go new file mode 100644 index 0000000..fefbc12 --- /dev/null +++ b/apps/model/vo/monitor.go @@ -0,0 +1,15 @@ +package vo + +type SystemMonitorVo struct { + HUIVersion string `json:"huiVersion"` + CpuPercent float64 `json:"cpuPercent"` + MemPercent float64 `json:"memPercent"` + DiskPercent float64 `json:"diskPercent"` +} + +type Hysteria2MonitorVo struct { + UserTotal int64 `json:"userTotal"` // 在线用户数 + DeviceTotal int64 `json:"deviceTotal"` // 在线设备数 + Version string `json:"version"` // 版本 + Running bool `json:"running"` // 运行状态 +} diff --git a/apps/model/vo/result.go b/apps/model/vo/result.go new file mode 100644 index 0000000..3e9e331 --- /dev/null +++ b/apps/model/vo/result.go @@ -0,0 +1,46 @@ +package vo + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/model/constant" + "net/http" +) + +type result struct { + Code int `json:"code"` + Type string `json:"type"` + Message string `json:"message"` + Data interface{} `json:"data"` +} + +const ( + TypeSuccess = "ok" + TypeError = "no" +) + +func Success(data interface{}, c *gin.Context) { + c.JSON(http.StatusOK, result{ + Code: constant.CodeSuccess, + Type: TypeSuccess, + Data: data, + }) +} + +func Fail(message string, c *gin.Context) { + var code int + if constant.UnauthorizedError == message { + code = constant.CodeUnauthorizedError + } else if constant.ForbiddenError == message { + code = constant.CodeForbiddenError + } else if constant.InvalidError == message { + code = constant.CodeInvalidError + } else { + code = constant.CodeSysError + } + c.JSON(http.StatusOK, result{ + Code: code, + Type: TypeError, + Message: message, + Data: nil, + }) +} diff --git a/apps/model/vo/vo.go b/apps/model/vo/vo.go new file mode 100644 index 0000000..6fa534a --- /dev/null +++ b/apps/model/vo/vo.go @@ -0,0 +1,8 @@ +package vo + +import "time" + +type BaseVo struct { + Id int64 `json:"id"` + CreateTime time.Time `json:"createTime"` +} diff --git a/apps/proxy/hysteria2.go b/apps/proxy/hysteria2.go new file mode 100644 index 0000000..a8be8d1 --- /dev/null +++ b/apps/proxy/hysteria2.go @@ -0,0 +1,58 @@ +package proxy + +import ( + "errors" + "github.com/sirupsen/logrus" + "hy2xs-admin/model/constant" + "hy2xs-admin/util" + "os/exec" + "sync" +) + +type Hysteria2Process struct { + process + binPath string + configPath string +} + +var mutexHysteria2 sync.Mutex +var cmdHysteria2 exec.Cmd +var hysteria2Instance *Hysteria2Process + +func init() { + hysteria2Instance = &Hysteria2Process{process{mutex: &mutexHysteria2, cmd: &cmdHysteria2}, util.GetHysteria2BinPath(), constant.Hysteria2ConfigPath} +} + +func NewHysteria2Instance() *Hysteria2Process { + return hysteria2Instance +} + +func (h *Hysteria2Process) IsRunning() bool { + return h.isRunning() +} + +func (h *Hysteria2Process) StartHysteria2() error { + if err := h.start(h.binPath, "-c", h.configPath, "server"); err != nil { + _ = util.RemoveFile(h.configPath) + logrus.Errorf("start hysteria2 err: %v", err) + return errors.New("start hysteria2 err") + } + return nil +} + +func (h *Hysteria2Process) StopHysteria2() error { + if err := h.stop(); err != nil { + logrus.Errorf("stop hysteria2 err: %v", err) + return errors.New("stop hysteria2 err") + } + _ = util.RemoveFile(h.configPath) + return nil +} + +func (h *Hysteria2Process) Release() error { + if err := h.release(); err != nil { + logrus.Errorf("release hysteria2 err: %v", err) + return errors.New("release hysteria2 err") + } + return nil +} diff --git a/apps/proxy/hysteria2_api.go b/apps/proxy/hysteria2_api.go new file mode 100644 index 0000000..9c40600 --- /dev/null +++ b/apps/proxy/hysteria2_api.go @@ -0,0 +1,136 @@ +package proxy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/sirupsen/logrus" + "hy2xs-admin/model/bo" + "hy2xs-admin/model/constant" + "io" + "net/http" + "time" +) + +type Hysteria2Api struct { + apiPort int64 +} + +func NewHysteria2Api(apiPort int64) *Hysteria2Api { + return &Hysteria2Api{ + apiPort: apiPort, + } +} + +// ListUsers 每个用户的流量信息 +func (h *Hysteria2Api) ListUsers(clear bool, secret string) (map[string]bo.Hysteria2UserTraffic, error) { + var users map[string]bo.Hysteria2UserTraffic + if !NewHysteria2Instance().IsRunning() { + return users, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + url := fmt.Sprintf("http://127.0.0.1:%d/traffic", h.apiPort) + if clear { + url = fmt.Sprintf("%s?clear=1", url) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + logrus.Errorf("Hysteria2 ListUsers NewRequest err: %v", err) + return nil, errors.New(constant.SysError) + } + req.Header.Set("Authorization", secret) + resp, err := http.DefaultClient.Do(req) + defer func() { + if resp != nil { + _ = resp.Body.Close() + } + }() + if err != nil || resp.StatusCode != http.StatusOK { + logrus.Errorf("Hysteria2 ListUsers err: %v", err) + return nil, errors.New("http connection error") + } + body, err := io.ReadAll(resp.Body) + if err != nil { + logrus.Errorf("Hysteria2 io read err: %v", err) + return nil, errors.New("http connection error") + } + if err = json.Unmarshal(body, &users); err != nil { + logrus.Errorf("Hysteria2 ListUsers Unmarshal err: %v", err) + return nil, errors.New(constant.SysError) + } + return users, nil +} + +// KickUsers 踢下线 +func (h *Hysteria2Api) KickUsers(keys []string, secret string) error { + if !NewHysteria2Instance().IsRunning() { + return nil + } + usernamesByte, err := json.Marshal(keys) + if err != nil { + logrus.Errorf("Hysteria2 KickUsers Marshal err: %v", err) + return errors.New(constant.SysError) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + url := fmt.Sprintf("http://127.0.0.1:%d/kick", h.apiPort) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, + bytes.NewBuffer(usernamesByte)) + if err != nil { + logrus.Errorf("Hysteria2 KickUsers NewRequest err: %v", err) + return errors.New(constant.SysError) + } + req.Header.Set("Authorization", secret) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + defer func() { + if resp != nil { + resp.Body.Close() + } + }() + if err != nil || resp.StatusCode != http.StatusOK { + logrus.Errorf("Hysteria2 KickUsers err: %v", err) + return errors.New("http connection error") + } + return nil +} + +// OnlineUsers 在线用户 +func (h *Hysteria2Api) OnlineUsers(secret string) (map[string]int64, error) { + var onlineUsers map[string]int64 + if !NewHysteria2Instance().IsRunning() { + return onlineUsers, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + url := fmt.Sprintf("http://127.0.0.1:%d/online", h.apiPort) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + logrus.Errorf("Hysteria2 OnlineUsers NewRequest err: %v", err) + return nil, errors.New(constant.SysError) + } + req.Header.Set("Authorization", secret) + resp, err := http.DefaultClient.Do(req) + defer func() { + if resp != nil { + _ = resp.Body.Close() + } + }() + if err != nil || resp.StatusCode != http.StatusOK { + logrus.Errorf("Hysteria2 OnlineUsers err: %v", err) + return nil, errors.New("http connection error") + } + body, err := io.ReadAll(resp.Body) + if err != nil { + logrus.Errorf("Hysteria2 io read err: %v", err) + return nil, errors.New("http connection error") + } + if err = json.Unmarshal(body, &onlineUsers); err != nil { + logrus.Errorf("Hysteria2 OnlineUsers Unmarshal err: %v", err) + return nil, errors.New(constant.SysError) + } + return onlineUsers, nil +} diff --git a/apps/proxy/process.go b/apps/proxy/process.go new file mode 100644 index 0000000..22b5b24 --- /dev/null +++ b/apps/proxy/process.go @@ -0,0 +1,229 @@ +package proxy + +import ( + "bufio" + "errors" + "fmt" + "github.com/sirupsen/logrus" + "gopkg.in/natefinch/lumberjack.v2" + "hy2xs-admin/model/constant" + "io" + "os/exec" + "sync" + "syscall" + "time" +) + +var logger logrus.Logger + +func initLogger() { + logger.SetOutput(&lumberjack.Logger{ + Filename: constant.Hysteria2LogPath, + MaxSize: 1, + MaxBackups: 2, + MaxAge: 30, + Compress: true, + LocalTime: true, + }) + logger.SetFormatter(&logrus.JSONFormatter{TimestampFormat: "2006-01-02 15:04:05"}) + logger.SetLevel(logrus.InfoLevel) +} + +func init() { + initLogger() +} + +type process struct { + mutex *sync.Mutex + cmd *exec.Cmd +} + +func (p *process) isRunning() bool { + return p.cmd != nil && p.cmd.Process != nil && p.cmd.ProcessState == nil +} + +func (p *process) start(name string, arg ...string) error { + if !p.mutex.TryLock() { + logrus.Errorf("start cmd err: lock not acquired") + return errors.New("start cmd err") + } + defer p.mutex.Unlock() + + if p.isRunning() { + return nil + } + + cmd := exec.Command(name, arg...) + if cmd.Err != nil { + logrus.Errorf("cmd err: %v", cmd.Err) + return errors.New("cmd err") + } + + // 获取命令的 stdout 和 stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + logrus.Errorf("Error obtaining stdout: %v", err) + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + logrus.Errorf("Error obtaining stderr: %v", err) + return err + } + + if err := cmd.Start(); err != nil { + logrus.Errorf("cmd start err: %v", err) + return errors.New("cmd start err") + } + + p.cmd = cmd + + go p.handleLogs(stdout, stderr) + + return nil +} + +func (p *process) stop() error { + if !p.mutex.TryLock() { + return errors.New("cmd stop err: lock not acquired") + } + + if !p.isRunning() { + p.mutex.Unlock() + return nil + } + + cmd := p.cmd + p.mutex.Unlock() + + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + logrus.Warnf("send SIGTERM failed: %v", err) + } + + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + + select { + case err := <-done: + if normalizeExitErr(err, syscall.SIGTERM) != nil { + return fmt.Errorf("process exit failed: %w", err) + } + + case <-timer.C: + if err := cmd.Process.Kill(); err != nil { + return fmt.Errorf("SIGKILL failed: %w", err) + } + + err := <-done + if normalizeExitErr(err, syscall.SIGKILL) != nil { + return fmt.Errorf("process killed but exit abnormal: %w", err) + } + } + + p.mutex.Lock() + p.cmd = nil + p.mutex.Unlock() + + return nil +} + +func normalizeExitErr(err error, allowedSignals ...syscall.Signal) error { + if err == nil { + return nil + } + + exitErr, ok := err.(*exec.ExitError) + if !ok { + return err + } + + status, ok := exitErr.Sys().(syscall.WaitStatus) + if !ok { + return err + } + + if status.Signaled() { + sig := status.Signal() + for _, s := range allowedSignals { + if sig == s { + return nil + } + } + } + + return err +} + +func (p *process) release() error { + if !p.mutex.TryLock() { + logrus.Errorf("cmd release err: lock not acquired") + return errors.New("cmd release err") + } + defer p.mutex.Unlock() + + if !p.isRunning() { + return nil + } + + if err := p.cmd.Process.Release(); err != nil { + logrus.Errorf("cmd release err: %v", err) + return errors.New("cmd release err") + } + p.cmd = nil + return nil +} + +func (p *process) handleLogs(stdout, stderr io.ReadCloser) { + // 日志 + stdoutChan := make(chan string) + stderrChan := make(chan string) + + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + stdoutChan <- scanner.Text() + } + if err := scanner.Err(); err != nil { + logrus.Errorf("Error reading stdout: %v", err) + } + close(stdoutChan) + }() + + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + stderrChan <- scanner.Text() + } + if err := scanner.Err(); err != nil { + logrus.Errorf("Error reading stderr: %v", err) + } + close(stderrChan) + }() + + for { + select { + case line, ok := <-stdoutChan: + if !ok { + stdoutChan = nil + } else { + logger.Infof(line) + } + case line, ok := <-stderrChan: + if !ok { + stderrChan = nil + } else { + logger.Errorf(line) + } + } + + // 当两个 channel 都关闭时,退出循环 + if stdoutChan == nil && stderrChan == nil { + break + } + } +} diff --git a/apps/router/account.go b/apps/router/account.go new file mode 100644 index 0000000..c83130c --- /dev/null +++ b/apps/router/account.go @@ -0,0 +1,23 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/controller" +) + +func initAccountAdminRouter(accountApi *gin.RouterGroup) { + account := accountApi.Group("/account") + { + account.GET("/pageAccount", controller.PageAccount) + account.POST("/saveAccount", controller.SaveAccount) + account.POST("/deleteAccount", controller.DeleteAccount) + account.POST("/updateAccount", controller.UpdateAccount) + account.POST("/resetTraffic", controller.ResetTraffic) + account.GET("/getAccountInfo", controller.GetAccountInfo) + account.GET("/getAccount", controller.GetAccount) + account.POST("/importAccount", controller.ImportAccount) + account.POST("/exportAccount", controller.ExportAccount) + account.POST("/releaseKickAccount", controller.ReleaseKickAccount) + account.GET("/verifyDefaultPass", controller.VerifyDefaultPass) + } +} diff --git a/apps/router/auth.go b/apps/router/auth.go new file mode 100644 index 0000000..569a477 --- /dev/null +++ b/apps/router/auth.go @@ -0,0 +1,13 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/controller" +) + +func initAuthRouter(authApi *gin.RouterGroup) { + auth := authApi.Group("/auth") + { + auth.POST("/login", controller.Login) + } +} diff --git a/apps/router/config.go b/apps/router/config.go new file mode 100644 index 0000000..7a8e6e4 --- /dev/null +++ b/apps/router/config.go @@ -0,0 +1,24 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/controller" +) + +func initConfigRouter(configApi *gin.RouterGroup) { + config := configApi.Group("/config") + { + config.POST("/updateConfigs", controller.UpdateConfigs) + config.GET("/getConfig", controller.GetConfig) + config.POST("/listConfig", controller.ListConfig) + config.GET("/getHysteria2Config", controller.GetHysteria2Config) + config.POST("/updateHysteria2Config", controller.UpdateHysteria2Config) + config.POST("/exportHysteria2Config", controller.ExportHysteria2Config) + config.POST("/importHysteria2Config", controller.ImportHysteria2Config) + config.POST("/exportConfig", controller.ExportConfig) + config.POST("/importConfig", controller.ImportConfig) + config.GET("/hysteria2AcmePath", controller.Hysteria2AcmePath) + config.POST("/restartServer", controller.RestartServer) + config.POST("/uploadCertFile", controller.UploadCertFile) + } +} diff --git a/apps/router/hysteria2.go b/apps/router/hysteria2.go new file mode 100644 index 0000000..ce2ceb6 --- /dev/null +++ b/apps/router/hysteria2.go @@ -0,0 +1,26 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/controller" +) + +func initHysteria2AuthRouter(hysteria2Api *gin.RouterGroup) { + hysteria2 := hysteria2Api.Group("/hysteria2") + { + hysteria2.POST("/auth", controller.Hysteria2Auth) + + } + hysteria2Api.GET("/:conPass", controller.Hysteria2Subscribe) +} + +func initHysteria2Router(hysteria2Api *gin.RouterGroup) { + hysteria2 := hysteria2Api.Group("/hysteria2") + { + hysteria2.POST("/hysteria2Kick", controller.Hysteria2Kick) + hysteria2.POST("/hysteria2ChangeVersion", controller.Hysteria2ChangeVersion) + hysteria2.GET("/listRelease", controller.ListRelease) + hysteria2.GET("/hysteria2SubscribeUrl", controller.Hysteria2SubscribeUrl) + hysteria2.GET("/hysteria2Url", controller.Hysteria2Url) + } +} diff --git a/apps/router/log.go b/apps/router/log.go new file mode 100644 index 0000000..452ddc9 --- /dev/null +++ b/apps/router/log.go @@ -0,0 +1,15 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/controller" +) + +func initLogRouter(accountApi *gin.RouterGroup) { + account := accountApi.Group("/log") + { + account.GET("/logSystem", controller.LogSystem) + account.GET("/logHysteria2", controller.LogHysteria2) + account.POST("/exportLog", controller.ExportLog) + } +} diff --git a/apps/router/monitor.go b/apps/router/monitor.go new file mode 100644 index 0000000..96792ee --- /dev/null +++ b/apps/router/monitor.go @@ -0,0 +1,14 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/controller" +) + +func initMonitorRouter(accountApi *gin.RouterGroup) { + account := accountApi.Group("/monitor") + { + account.GET("/monitorSystem", controller.MonitorSystem) + account.GET("/monitorHysteria2", controller.MonitorHysteria2) + } +} diff --git a/apps/router/router.go b/apps/router/router.go new file mode 100644 index 0000000..69cdeaf --- /dev/null +++ b/apps/router/router.go @@ -0,0 +1,41 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "hy2xs-admin/frontend" + "hy2xs-admin/middleware" + "strings" +) + +func Router(router *gin.Engine, huiWebContext *string) { + // global context + relativePath := "/" + if huiWebContext != nil && strings.HasPrefix(*huiWebContext, "/") { + relativePath = *huiWebContext + } + globalGroup := router.Group(relativePath) + { + globalGroup.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler()) + + frontend.InitFrontend(router, relativePath) + + authApi := globalGroup.Group("/hui") + { + initAuthRouter(authApi) + initHysteria2AuthRouter(authApi) + } + + globalGroup.Use(middleware.JWTHandler()) + + globalGroup.Use(middleware.AdminHandler()) + + huiAdminApi := globalGroup.Group("/hui") + { + initAccountAdminRouter(huiAdminApi) + initConfigRouter(huiAdminApi) + initHysteria2Router(huiAdminApi) + initLogRouter(huiAdminApi) + initMonitorRouter(huiAdminApi) + } + } +} diff --git a/apps/service/account.go b/apps/service/account.go new file mode 100644 index 0000000..0657e38 --- /dev/null +++ b/apps/service/account.go @@ -0,0 +1,158 @@ +package service + +import ( + "errors" + "fmt" + "github.com/gin-gonic/gin" + "hy2xs-admin/dao" + "hy2xs-admin/model/bo" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/dto" + "hy2xs-admin/model/entity" + "hy2xs-admin/model/vo" +) + +func Login(username string, pass string) (string, error) { + account, err := dao.GetAccount("username = ? and pass = ? and role = 'admin' and deleted = 0", username, pass) + if err != nil { + return "", err + } + accountBo := bo.AccountBo{ + Id: *account.Id, + Username: *account.Username, + Roles: []string{*account.Role}, + Deleted: *account.Deleted, + } + return GenToken(accountBo) +} + +func PageAccount(accountPageDto dto.AccountPageDto) ([]entity.Account, int64, error) { + return dao.PageAccount(accountPageDto) +} + +func SaveAccount(account entity.Account) error { + _, err := dao.SaveAccount(account) + return err +} + +func DeleteAccount(ids []int64) error { + return dao.DeleteAccount(ids) +} + +func UpdateAccount(account entity.Account) error { + updates := map[string]interface{}{} + if account.Username != nil && *account.Username != "" { + updates["username"] = *account.Username + } + if account.Pass != nil && *account.Pass != "" { + updates["pass"] = *account.Pass + } + if account.ConPass != nil && *account.ConPass != "" { + updates["con_pass"] = fmt.Sprintf("%s.%s", *account.Username, *account.ConPass) + } + if account.Quota != nil { + updates["quota"] = *account.Quota + } + if account.ExpireTime != nil { + updates["expire_time"] = *account.ExpireTime + } + if account.Download != nil { + updates["download"] = *account.Download + } + if account.Upload != nil { + updates["upload"] = *account.Upload + } + if account.DeviceNo != nil { + updates["device_no"] = *account.DeviceNo + } + if account.Deleted != nil { + updates["deleted"] = *account.Deleted + } + if account.LoginAt != nil && *account.LoginAt > 0 { + updates["login_at"] = *account.LoginAt + } + if account.ConAt != nil && *account.ConAt > 0 { + updates["con_at"] = *account.ConAt + } + if account.Remark != nil { + updates["remark"] = *account.Remark + } + return dao.UpdateAccount([]int64{*account.Id}, updates) +} + +func ResetTraffic(id int64) error { + return dao.UpdateAccount([]int64{id}, map[string]interface{}{"download": 0, "upload": 0}) +} + +func ExistAccountUsername(username string, id int64) bool { + var err error + if id != 0 { + _, err = dao.GetAccount("username = ? and id != ?", username, id) + } else { + _, err = dao.GetAccount("username = ?", username) + } + if err != nil { + if err.Error() == constant.WrongPassword { + return false + } + } + return true +} + +func GetAccount(id int64) (entity.Account, error) { + return dao.GetAccount("id = ?", id) +} + +func ListExportAccount() ([]bo.AccountExport, error) { + accounts, err := dao.ListAccount(nil, nil) + if err != nil { + return nil, errors.New(constant.SysError) + } + var accountExports []bo.AccountExport + for _, item := range accounts { + accountExport := bo.AccountExport{ + Id: *item.Id, + Username: *item.Username, + Pass: *item.Pass, + ConPass: *item.ConPass, + Quota: *item.Quota, + Download: *item.Download, + Upload: *item.Upload, + ExpireTime: *item.ExpireTime, + DeviceNo: *item.DeviceNo, + KickUtilTime: *item.KickUtilTime, + Role: *item.Role, + Deleted: *item.Deleted, + CreateTime: *item.CreateTime, + UpdateTime: *item.UpdateTime, + LoginAt: *item.LoginAt, + ConAt: *item.ConAt, + Remark: *item.Remark, + } + accountExports = append(accountExports, accountExport) + } + return accountExports, nil +} + +func ReleaseKickAccount(id int64) error { + return dao.UpdateAccount([]int64{id}, map[string]interface{}{"kick_util_time": 0}) +} + +func UpsertAccount(accounts []entity.Account) error { + return dao.UpsertAccount(accounts) +} + +func GetAccountInfo(c *gin.Context) (vo.AccountInfoVo, error) { + myClaims, err := ParseToken(GetToken(c)) + if err != nil { + return vo.AccountInfoVo{}, err + } + if myClaims.AccountBo.Deleted != 0 { + return vo.AccountInfoVo{}, errors.New("this account has been disabled") + } + return vo.AccountInfoVo{ + Id: myClaims.AccountBo.Id, + Username: myClaims.AccountBo.Username, + Roles: myClaims.AccountBo.Roles, + }, nil +} diff --git a/apps/service/config.go b/apps/service/config.go new file mode 100644 index 0000000..611765b --- /dev/null +++ b/apps/service/config.go @@ -0,0 +1,197 @@ +package service + +import ( + "errors" + "fmt" + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + "hy2xs-admin/dao" + "hy2xs-admin/model/bo" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/entity" + "os" + "strconv" + "strings" +) + +func UpdateConfig(key string, value string) error { + if key == constant.Hysteria2Enable { + if value == "1" { + hysteria2Config, err := GetHysteria2Config() + if err != nil { + return err + } + if hysteria2Config.Listen == nil || *hysteria2Config.Listen == "" { + logrus.Errorf("hysteria2 config is empty") + return errors.New("hysteria2 config is empty") + } + // 启动Hysteria2 + if err = StartHysteria2(); err != nil { + return err + } + } else { + if err := StopHysteria2(); err != nil { + return err + } + } + } + return dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value}) +} + +func GetConfig(key string) (entity.Config, error) { + return dao.GetConfig("key = ?", key) +} + +func ListConfig(keys []string) ([]entity.Config, error) { + return dao.ListConfig("key in ?", keys) +} + +func ListConfigNotIn(keys []string) ([]entity.Config, error) { + return dao.ListConfig("key not in ?", keys) +} + +func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) { + var serverConfig bo.Hysteria2ServerConfig + config, err := dao.GetConfig("key = ?", constant.Hysteria2Config) + if err != nil { + return serverConfig, err + } + if config.Value == nil || strings.TrimSpace(*config.Value) == "" { + content, readErr := os.ReadFile(constant.Hysteria2ConfigPath) + if readErr != nil { + return serverConfig, readErr + } + if err = yaml.Unmarshal(content, &serverConfig); err != nil { + return serverConfig, err + } + return serverConfig, nil + } + if err = yaml.Unmarshal([]byte(*config.Value), &serverConfig); err != nil { + return serverConfig, err + } + return serverConfig, nil +} + +func UpdateHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error { + // 默认值 + config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.JwtSecret}) + if err != nil { + return err + } + + var hUIWebPort string + var jwtSecret string + for _, item := range config { + if *item.Key == constant.HUIWebPort { + hUIWebPort = *item.Value + } else if *item.Key == constant.JwtSecret { + jwtSecret = *item.Value + } + } + + if hUIWebPort == "" || jwtSecret == "" { + logrus.Errorf("hUIWebPort or jwtSecret is nil") + return errors.New(constant.SysError) + } + + authHttpUrl, err := GetAuthHttpUrl() + if err != nil { + return err + } + + authType := "http" + authHttpInsecure := true + var auth bo.ServerConfigAuth + auth.Type = &authType + var http bo.ServerConfigAuthHTTP + http.URL = &authHttpUrl + http.Insecure = &authHttpInsecure + auth.HTTP = &http + hysteria2ServerConfig.Auth = &auth + hysteria2ServerConfig.TrafficStats.Secret = &jwtSecret + + yamlConfig, err := yaml.Marshal(&hysteria2ServerConfig) + if err != nil { + return err + } + return dao.UpdateConfig([]string{constant.Hysteria2Config}, map[string]interface{}{"value": string(yamlConfig)}) +} + +func SetHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error { + config, err := yaml.Marshal(&hysteria2ServerConfig) + if err != nil { + return err + } + return dao.UpdateConfig([]string{constant.Hysteria2Config}, map[string]interface{}{"value": string(config)}) +} + +func UpsertConfig(configs []entity.Config) error { + return dao.UpsertConfig(configs) +} + +func GetHysteria2ApiPort() (int64, error) { + hysteria2Config, err := GetHysteria2Config() + if err != nil { + return 0, err + } + if hysteria2Config.TrafficStats == nil || hysteria2Config.TrafficStats.Listen == nil { + errMsg := "hysteria2 Traffic Stats API (HTTP) Listen is nil" + logrus.Errorf(errMsg) + return 0, errors.New(errMsg) + } + apiPort, err := strconv.ParseInt(strings.Split(*hysteria2Config.TrafficStats.Listen, ":")[1], 10, 64) + if err != nil { + errMsg := fmt.Sprintf("apiPort: %s is invalid", *hysteria2Config.TrafficStats.Listen) + logrus.Errorf(errMsg) + return 0, errors.New(errMsg) + } + return apiPort, nil +} + +func GetPortAndCert() (int64, string, string, error) { + configs, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.HUICrtPath, constant.HUIKeyPath}) + if err != nil { + return 0, "", "", err + } + port := "" + crtPath := "" + keyPath := "" + for _, config := range configs { + value := *config.Value + if *config.Key == constant.HUIWebPort { + port = value + } else if *config.Key == constant.HUICrtPath { + crtPath = value + } else if *config.Key == constant.HUIKeyPath { + keyPath = value + } + } + + portInt, err := strconv.ParseInt(port, 10, 64) + if err != nil { + logrus.Errorf("port: %s is invalid", port) + return 0, "", "", errors.New(fmt.Sprintf("port: %s is invalid", port)) + } + + return portInt, crtPath, keyPath, nil +} + +func GetAuthHttpUrl() (string, error) { + port, crtPath, keyPath, err := GetPortAndCert() + if err != nil { + return "", err + } + protocol := "http" + if crtPath != "" && keyPath != "" { + protocol = "https" + } + config, err := dao.GetConfig("key = ?", constant.HUIWebContext) + if err != nil { + return "", err + } + webContext := "" + if config.Value != nil && *config.Value != "/" && strings.HasPrefix(*config.Value, "/") { + webContext = *config.Value + } + return fmt.Sprintf("%s://127.0.0.1:%d%s/hui/hysteria2/auth", protocol, port, webContext), nil +} diff --git a/apps/service/cron.go b/apps/service/cron.go new file mode 100644 index 0000000..e5c979a --- /dev/null +++ b/apps/service/cron.go @@ -0,0 +1,140 @@ +package service + +import ( + "github.com/sirupsen/logrus" + "hy2xs-admin/dao" + "hy2xs-admin/model/bo" + "hy2xs-admin/model/constant" + "hy2xs-admin/proxy" + "hy2xs-admin/util" + "strconv" + "sync" + "time" +) + +var trafficMutex sync.Mutex +var kickMutex sync.Mutex + +func CronHandleAccount() { + go func() { + hysteriaEnable, err := dao.GetConfig("key = ?", constant.Hysteria2Enable) + if err != nil { + return + } + if hysteriaEnable.Value != nil && *hysteriaEnable.Value == "1" { + apiPort, err := GetHysteria2ApiPort() + if err != nil { + return + } + + jwtSecretConfig, err := dao.GetConfig("key = ?", constant.JwtSecret) + if err != nil { + return + } + + // 保存流量数据 + go saveAccountTraffic(apiPort, *jwtSecretConfig.Value) + + // 踢下线 + go kickAccount(apiPort, *jwtSecretConfig.Value) + } + }() +} + +func CronResetTraffic() { + accounts, err := dao.ListAccount(nil, nil) + if err != nil { + return + } + var ids []int64 + for _, item := range accounts { + ids = append(ids, *item.Id) + } + idsList := util.SplitArr(ids, 100) + for _, item := range idsList { + if err := dao.UpdateAccount(item, map[string]interface{}{"download": 0, "upload": 0}); err != nil { + continue + } + } +} + +func saveAccountTraffic(apiPort int64, jwtSecret string) { + if !trafficMutex.TryLock() { + return + } + defer trafficMutex.Unlock() + + hysteria2TrafficTime, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficTime) + if err != nil { + return + } + hysteria2TrafficTimeFloat, err := strconv.ParseFloat(*hysteria2TrafficTime.Value, 64) + if err != nil { + logrus.Errorf("hysteria2TrafficTime string conv int64 err: %v", err) + return + } + + users, err := proxy.NewHysteria2Api(apiPort).ListUsers(true, jwtSecret) + if err != nil { + return + } + if len(users) > 0 { + userLists := util.SplitMap(users, 10) + var wg sync.WaitGroup + for _, userList := range userLists { + wg.Add(1) + go func(userList map[string]bo.Hysteria2UserTraffic) { + defer wg.Done() + for username, traffic := range userList { + if err = dao.UpdateAccountTraffic(username, int64(float64(traffic.Rx)*hysteria2TrafficTimeFloat), int64(float64(traffic.Tx)*hysteria2TrafficTimeFloat)); err != nil { + continue + } + } + }(userList) + } + wg.Wait() + } +} + +func kickAccount(apiPort int64, jwtSecret string) { + if !kickMutex.TryLock() { + return + } + defer kickMutex.Unlock() + + users, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(jwtSecret) + if err != nil { + return + } + if len(users) > 0 { + i := 0 + usernames := make([]string, len(users)) + for k := range users { + usernames[i] = k + i++ + } + usernameLists := util.SplitArr(usernames, 10) + var wg sync.WaitGroup + for _, usernameList := range usernameLists { + wg.Add(1) + go func(usernameList []string) { + defer wg.Done() + now := time.Now().UnixMilli() + accounts, err := dao.ListAccount("username in ? and (deleted = 1 or (quota > 0 and quota < download + upload)) or ? > expire_time or ? < kick_util_time", usernameList, now, now) + if err != nil { + return + } + kickUsernames := make([]string, len(accounts)) + j := 0 + for _, item := range accounts { + kickUsernames[j] = *item.Username + j++ + } + if err = proxy.NewHysteria2Api(apiPort).KickUsers(kickUsernames, jwtSecret); err != nil { + return + } + }(usernameList) + } + wg.Wait() + } +} diff --git a/apps/service/forward.go b/apps/service/forward.go new file mode 100644 index 0000000..a975e41 --- /dev/null +++ b/apps/service/forward.go @@ -0,0 +1,234 @@ +package service + +import ( + "errors" + "fmt" + "hy2xs-admin/dao" + "hy2xs-admin/model/constant" + "hy2xs-admin/util" + "strings" +) + +var ( + netManager string + ingressInterface string + Add = "add" + Delete = "delete" + Table = "hui_porthopping" + Comment = "hui_hysteria_porthopping" +) + +func InitForward() { + if nft, err := util.Exec("command -v nft"); err == nil && strings.TrimSpace(nft) != "" { + netManager = "nft" + } else if iptables, err := util.Exec("command -v iptables"); err == nil && strings.TrimSpace(iptables) != "" { + netManager = "iptables" + } + + if ii, err := util.Exec("ls /sys/class/net | grep -E '^en|^eth'"); err == nil && strings.TrimSpace(ii) != "" { + iiList := strings.Split(ii, "\n") + ingressInterface = strings.TrimSpace(iiList[0]) + } +} + +func InitTableAndChain() error { + if netManager == "nft" { + _, err := util.Exec(fmt.Sprintf("nft add table inet %s", Table)) + if err != nil { + return err + } + _, err = util.Exec(fmt.Sprintf("nft add chain inet %s prerouting { type nat hook prerouting priority dstnat\\; policy accept\\; }", Table)) + if err != nil { + return err + } + } + return nil +} + +func InitPortHopping() error { + if err := RemoveByComment(); err != nil { + return err + } + + hysteria2Config, err := GetHysteria2Config() + if err != nil { + return err + } + + // set port forward + hysteria2ConfigPortHopping, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigPortHopping) + if err != nil { + return err + } + if *hysteria2ConfigPortHopping.Value != "" { + listen := strings.Split(*hysteria2Config.Listen, ":") + if len(listen) == 2 { + portHoppings := strings.Split(*hysteria2ConfigPortHopping.Value, ",") + for _, item := range portHoppings { + if err := portForward(item, listen[1], Add); err != nil { + return err + } + } + } + } + return nil +} + +func portForward(rules string, target string, option string) error { + switch netManager { + case "nft": + switch option { + case Add, Delete: + return nftForward(rules, target, option) + default: + return errors.New("unsupported command option") + } + case "iptables": + switch option { + case Add: + return iptablesForward(rules, target, "-A") + case Delete: + return iptablesForward(rules, target, "-D") + default: + return errors.New("unsupported command option") + } + default: + return errors.New("port hopping not supported on this system") + } +} + +func RemoveByComment() error { + switch netManager { + case "nft": + return ntfRemoveByComment(Comment) + case "iptables": + return iptablesRemoveByComment(Comment) + default: + return errors.New("port hopping not supported on this system") + } +} + +func nftForward(rules string, target string, option string) error { + if ingressInterface == "" { + return fmt.Errorf("no network interface detected") + } + // nft list ruleset + // 创建表:nft add table inet hui_hysteria_porthopping + // 创建链:nft add chain inet hui_hysteria_porthopping prerouting { type nat hook prerouting priority dstnat\; policy accept\; } + // 添加规则:nft add rule inet hui_hysteria_porthopping prerouting iifname enp1s0 udp dport {30000-40000} counter redirect to :444 comment hui_hysteria_porthopping + _, err := util.Exec(fmt.Sprintf("nft %s rule inet %s prerouting iifname %s udp dport {%s} counter redirect to :%s comment %s", option, Table, ingressInterface, rules, target, Comment)) + if err != nil { + return err + } + + return nil +} + +func ntfRemoveByComment(comment string) error { + rules, err := nftRules() + if err != nil { + return err + } + for _, rule := range rules { + if strings.Contains(rule, comment) { + parts := strings.Fields(rule) + handle := parts[len(parts)-1] + _, err := util.Exec(fmt.Sprintf("nft delete rule inet %s prerouting handle %s", Table, strings.TrimSpace(handle))) + if err != nil { + return err + } + } + } + return nil +} + +func nftRules() ([]string, error) { + listOutput, err := util.Exec(fmt.Sprintf("nft list ruleset | grep -q %s && echo 'found' || echo 'not found'", Comment)) + if err != nil { + return nil, err + } + if strings.TrimSpace(listOutput) == "not found" { + return []string{}, nil + } + output, err := util.Exec(fmt.Sprintf("nft --handle list chain inet %s prerouting", Table)) + if err != nil { + return nil, err + } + + rules := strings.Split(output, "\n") + return rules, nil +} + +func iptablesForward(rules string, target string, option string) error { + if ingressInterface == "" { + return fmt.Errorf("no network interface detected") + } + + rulePairs := strings.Split(rules, ",") + for _, pair := range rulePairs { + ports := "" + portRange := strings.Split(pair, "-") + if len(portRange) == 1 { + ports = strings.TrimSpace(portRange[0]) + } else if len(portRange) == 2 { + startPort := strings.TrimSpace(portRange[0]) + endPort := strings.TrimSpace(portRange[1]) + ports = startPort + ":" + endPort + } else { + return fmt.Errorf("invalid port range format: %s", pair) + } + + if len(ports) != 0 { + if err := iptablesAddRule(option, ports, target); err != nil { + return err + } + } + } + + return nil +} + +func iptablesAddRule(option, ports, target string) error { + protocols := [2]string{"iptables", "ip6tables"} + for _, protocol := range protocols { + // iptables -t nat -A PREROUTING -i enp1s0 -p udp --dport 30000:40000 -j REDIRECT --to-port 444 -m comment --comment hui_hysteria_porthopping + _, err := util.Exec(fmt.Sprintf("%s -t nat %s PREROUTING -i %s -p udp --dport %s -j REDIRECT --to-port %s -m comment --comment %s", protocol, option, ingressInterface, ports, target, Comment)) + if err != nil { + return err + } + } + return nil +} + +func iptablesRemoveByComment(comment string) error { + protocols := [2]string{"iptables", "ip6tables"} + for _, protocol := range protocols { + rules, err := iptablesRules(protocol) + if err != nil { + return err + } + for _, rule := range rules { + if strings.Contains(rule, comment) { + parts := strings.Fields(rule) + handle := parts[0] + _, err := util.Exec(fmt.Sprintf("%s -t nat -D PREROUTING %s", protocol, strings.TrimSpace(handle))) + if err != nil { + return err + } + } + } + } + + return nil +} + +func iptablesRules(protocol string) ([]string, error) { + // iptables -t nat -L PREROUTING -v --line-numbers + output, err := util.Exec(fmt.Sprintf("%s -t nat -L PREROUTING -v --line-numbers", protocol)) + if err != nil { + return nil, err + } + + rules := strings.Split(output, "\n") + return rules, nil +} diff --git a/apps/service/hysteria2.go b/apps/service/hysteria2.go new file mode 100644 index 0000000..7135061 --- /dev/null +++ b/apps/service/hysteria2.go @@ -0,0 +1,149 @@ +package service + +import ( + "errors" + "fmt" + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + "hy2xs-admin/dao" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/vo" + "hy2xs-admin/util" + "os" +) + +func InitHysteria2() error { + if !util.Exists(util.GetHysteria2BinPath()) { + return errors.New("systemd-managed hysteria binary not found") + } + + config, err := dao.GetConfig("key = ?", constant.Hysteria2Enable) + if err != nil { + return err + } + + if *config.Value == "1" { + logrus.Infof("hysteria2 lifecycle is managed by systemd in HY2XS production package") + } + return nil +} + +func setHysteria2ConfigYAML() error { + serverConfig, err := GetHysteria2Config() + if err != nil { + return err + } + if serverConfig.Listen == nil || *serverConfig.Listen == "" { + return errors.New("hysteria2 config is empty") + } + + authHttpUrl, err := GetAuthHttpUrl() + if err != nil { + return err + } + + if serverConfig.Auth == nil || serverConfig.Auth.HTTP == nil || serverConfig.Auth.HTTP.URL == nil { + if err := UpdateHysteria2Config(serverConfig); err != nil { + return err + } + serverConfig, err = GetHysteria2Config() + if err != nil { + return err + } + } + + // update auth http url + if *serverConfig.Auth.HTTP.URL != authHttpUrl { + serverConfig.Auth.HTTP.URL = &authHttpUrl + if err := UpdateHysteria2Config(serverConfig); err != nil { + return err + } + } + + hysteria2Config, err := yaml.Marshal(&serverConfig) + if err != nil { + logrus.Errorf("marshal hysteria2 config err: %v", err) + return errors.New("marshal hysteria2 config err") + } + file, err := os.OpenFile(constant.Hysteria2ConfigPath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) + if err != nil { + logrus.Errorf("create hysteria2 server config file err: %v", err) + return errors.New("create hysteria2 server config file err") + } + _, err = file.WriteString(string(hysteria2Config)) + if err != nil { + logrus.Errorf("write hysteria2 config.json file err: %v", err) + return errors.New("hysteria2 config.json file write err") + } + return nil +} + +func Hysteria2IsRunning() bool { + _, err := util.Exec("systemctl is-active --quiet hysteria-server") + return err == nil +} + +func StartHysteria2() error { + if err := setHysteria2ConfigYAML(); err != nil { + return err + } + return util.Systemctl("restart", "hysteria-server") +} + +func StopHysteria2() error { + return util.Systemctl("stop", "hysteria-server") +} + +func RestartHysteria2() error { + if err := StopHysteria2(); err != nil { + return err + } + if err := StartHysteria2(); err != nil { + return err + } + return nil +} + +func ReleaseHysteria2() error { + return nil +} + +func Hysteria2AcmePath() (vo.Hysteria2AcmePathVo, error) { + hysteria2AcmePathVo := vo.Hysteria2AcmePathVo{} + hysteria2Config, err := GetHysteria2Config() + if err != nil { + return hysteria2AcmePathVo, err + } + if hysteria2Config.TLS != nil && + hysteria2Config.TLS.Cert != nil && *hysteria2Config.TLS.Cert != "" && + hysteria2Config.TLS.Key != nil && *hysteria2Config.TLS.Key != "" { + if util.Exists(*hysteria2Config.TLS.Cert) && util.Exists(*hysteria2Config.TLS.Key) { + hysteria2AcmePathVo.CrtPath = *hysteria2Config.TLS.Cert + hysteria2AcmePathVo.KeyPath = *hysteria2Config.TLS.Key + return hysteria2AcmePathVo, nil + } + return hysteria2AcmePathVo, errors.New("cert not found") + } else if hysteria2Config.ACME != nil && + hysteria2Config.ACME.Domains != nil && + len(hysteria2Config.ACME.Domains) > 0 && + hysteria2Config.ACME.CA != nil && + *hysteria2Config.ACME.CA != "" && + hysteria2Config.ACME.Dir != nil && + *hysteria2Config.ACME.Dir != "" { + acmeDir := *hysteria2Config.ACME.Dir + for _, domain := range hysteria2Config.ACME.Domains { + crtPath, err := util.FindFile(acmeDir, fmt.Sprintf("%s.crt", domain)) + if err != nil { + continue + } + keyPath, err := util.FindFile(acmeDir, fmt.Sprintf("%s.key", domain)) + if err != nil { + continue + } + hysteria2AcmePathVo.CrtPath = crtPath + hysteria2AcmePathVo.KeyPath = keyPath + return hysteria2AcmePathVo, nil + } + } + return vo.Hysteria2AcmePathVo{}, errors.New("cert not found") +} diff --git a/apps/service/hysteria2_api.go b/apps/service/hysteria2_api.go new file mode 100644 index 0000000..0c718bb --- /dev/null +++ b/apps/service/hysteria2_api.go @@ -0,0 +1,282 @@ +package service + +import ( + "errors" + "fmt" + "gopkg.in/yaml.v3" + "hy2xs-admin/dao" + "hy2xs-admin/model/bo" + "hy2xs-admin/model/constant" + "hy2xs-admin/proxy" + "net/url" + "strings" + "time" +) + +func Hysteria2Auth(conPass string) (int64, string, error) { + if !Hysteria2IsRunning() { + return 0, "", errors.New("hysteria2 is not running") + } + + now := time.Now().UnixMilli() + account, err := dao.GetAccount("con_pass = ? and deleted = 0 and (quota < 0 or quota > download + upload) and ? < expire_time and ? > kick_util_time", conPass, now, now) + if err != nil { + return 0, "", err + } + + // 限制设备数 + onlineUsers, err := Hysteria2Online() + if err != nil { + return 0, "", err + } + device, exist := onlineUsers[*account.Username] + if exist && *account.DeviceNo <= device { + return 0, "", errors.New("device limited") + } + + return *account.Id, *account.Username, nil +} + +func Hysteria2Online() (map[string]int64, error) { + if !Hysteria2IsRunning() { + return map[string]int64{}, nil + } + apiPort, err := GetHysteria2ApiPort() + if err != nil { + return nil, errors.New("get hysteria2 apiPort err") + } + jwtSecretConfig, err := dao.GetConfig("key = ?", constant.JwtSecret) + if err != nil { + return nil, err + } + onlineUsers, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(*jwtSecretConfig.Value) + if err != nil { + return nil, err + } + return onlineUsers, nil +} + +func Hysteria2Kick(ids []int64, kickUtilTime int64) error { + if !Hysteria2IsRunning() { + return errors.New("hysteria2 is not running") + } + if err := dao.UpdateAccount(ids, map[string]interface{}{"kick_util_time": kickUtilTime}); err != nil { + return err + } + + accounts, err := dao.ListAccount("id in ?", ids) + if err != nil { + return err + } + var keys []string + for _, item := range accounts { + keys = append(keys, *item.Username) + } + apiPort, err := GetHysteria2ApiPort() + if err != nil { + return errors.New("get hysteria2 apiPort err") + } + jwtSecretConfig, err := dao.GetConfig("key = ?", constant.JwtSecret) + if err != nil { + return err + } + if err = proxy.NewHysteria2Api(apiPort).KickUsers(keys, *jwtSecretConfig.Value); err != nil { + return err + } + return nil +} + +func Hysteria2SubscribeUrl(accountId int64, protocol string, host string) (string, error) { + account, err := dao.GetAccount("id = ?", accountId) + if err != nil { + return "", err + } + config, err := dao.GetConfig("key = ?", constant.HUIWebContext) + if err != nil { + return "", err + } + webContext := "" + if config.Value != nil && *config.Value != "/" && strings.HasPrefix(*config.Value, "/") { + webContext = *config.Value + } + return fmt.Sprintf("%s//%s%s/hui/%s", protocol, host, webContext, url.QueryEscape(*account.ConPass)), nil +} + +func Hysteria2Subscribe(conPass string, clientType string, host string) (string, string, error) { + hysteria2Config, err := GetHysteria2Config() + if err != nil { + return "", "", err + } + if hysteria2Config.Listen == nil || *hysteria2Config.Listen == "" { + return "", "", errors.New("hysteria2 config is empty") + } + + account, err := dao.GetAccount("con_pass = ?", conPass) + if err != nil { + return "", "", err + } + + hysteria2Name := "hysteria2" + hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark) + if err != nil { + return "", "", err + } + if *hysteria2ConfigRemark.Value != "" { + hysteria2Name = *hysteria2ConfigRemark.Value + } + + hysteria2ConfigPortHopping, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigPortHopping) + if err != nil { + return "", "", err + } + + userInfo := "" + configStr := "" + if clientType == constant.Shadowrocket || clientType == constant.Clash { + userInfo = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", + *account.Upload, + *account.Download, + *account.Quota, + *account.ExpireTime/1000) + + hysteria2 := bo.Hysteria2{ + Name: hysteria2Name, + Type: "hysteria2", + Server: strings.Split(host, ":")[0], + Port: strings.Split(*hysteria2Config.Listen, ":")[1], + Ports: *hysteria2ConfigPortHopping.Value, + Password: conPass, + } + + if hysteria2Config.Bandwidth != nil { + if hysteria2Config.Bandwidth.Up != nil && + *hysteria2Config.Bandwidth.Up != "" { + hysteria2.Up = *hysteria2Config.Bandwidth.Up + } + if hysteria2Config.Bandwidth.Down != nil && + *hysteria2Config.Bandwidth.Down != "" { + hysteria2.Down = *hysteria2Config.Bandwidth.Down + } + } + + if hysteria2Config.Obfs != nil && + hysteria2Config.Obfs.Type != nil && + *hysteria2Config.Obfs.Type == "salamander" && + hysteria2Config.Obfs.Salamander != nil && + hysteria2Config.Obfs.Salamander.Password != nil && + *hysteria2Config.Obfs.Salamander.Password != "" { + if clientType == constant.Shadowrocket { + hysteria2.Obfs = *hysteria2Config.Obfs.Salamander.Password + } else { + hysteria2.Obfs = "salamander" + hysteria2.ObfsPassword = *hysteria2Config.Obfs.Salamander.Password + } + } + + if hysteria2Config.ACME != nil && + hysteria2Config.ACME.Domains != nil && + len(hysteria2Config.ACME.Domains) > 0 { + hysteria2.Sni = hysteria2Config.ACME.Domains[0] + } + + hysteria2.SkipCertVerify = false + + proxyGroup := bo.ProxyGroup{ + Name: "PROXY", + Type: "select", + Proxies: []string{hysteria2Name}, + } + + clashConfig := bo.ClashConfig{ + ProxyGroups: []bo.ProxyGroup{ + proxyGroup, + }, + Proxies: []interface{}{hysteria2}, + } + clashConfigYaml, err := yaml.Marshal(&clashConfig) + if err != nil { + return "", "", err + } + configStr = string(clashConfigYaml) + if clientType == constant.Clash { + clashExtension, err := GetConfig(constant.ClashExtension) + if err != nil { + return "", "", err + } + if clashExtension.Value != nil && *clashExtension.Value != "" { + configStr = fmt.Sprintf("%s%s", configStr, *clashExtension.Value) + } + } + } else if clientType == constant.V2rayN { + hysteria2Url, err := Hysteria2Url(*account.Id, strings.Split(host, ":")[0]) + if err != nil { + return "", "", err + } + configStr = hysteria2Url + } + + return userInfo, configStr, nil +} + +func Hysteria2Url(accountId int64, hostname string) (string, error) { + hysteria2Config, err := GetHysteria2Config() + if err != nil { + return "", err + } + if hysteria2Config.Listen == nil || *hysteria2Config.Listen == "" { + return "", errors.New("hysteria2 config is empty") + } + + account, err := dao.GetAccount("id = ?", accountId) + if err != nil { + return "", err + } + + urlConfig := "" + if hysteria2Config.Obfs != nil && + hysteria2Config.Obfs.Type != nil && + *hysteria2Config.Obfs.Type == "salamander" && + hysteria2Config.Obfs.Salamander != nil && + hysteria2Config.Obfs.Salamander.Password != nil && + *hysteria2Config.Obfs.Salamander.Password != "" { + urlConfig += fmt.Sprintf("&obfs=salamander&obfs-password=%s", *hysteria2Config.Obfs.Salamander.Password) + } + + if hysteria2Config.ACME != nil && + hysteria2Config.ACME.Domains != nil && + len(hysteria2Config.ACME.Domains) > 0 { + urlConfig += fmt.Sprintf("&sni=%s", hysteria2Config.ACME.Domains[0]) + // shadowrocket + urlConfig += fmt.Sprintf("&peer=%s", hysteria2Config.ACME.Domains[0]) + } + + urlConfig += "&insecure=0" + + if hysteria2Config.Bandwidth != nil && + hysteria2Config.Bandwidth.Down != nil && + *hysteria2Config.Bandwidth.Down != "" { + // shadowrocket + 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) + if err != nil { + return "", err + } + if *hysteria2ConfigRemark.Value != "" { + urlConfig += fmt.Sprintf("#%s", *hysteria2ConfigRemark.Value) + } + if urlConfig != "" { + urlConfig = "/?" + strings.TrimPrefix(urlConfig, "&") + } + return fmt.Sprintf("hysteria2://%s@%s%s", *account.ConPass, hostname, *hysteria2Config.Listen) + urlConfig, nil +} diff --git a/apps/service/jwt.go b/apps/service/jwt.go new file mode 100644 index 0000000..7ec344c --- /dev/null +++ b/apps/service/jwt.go @@ -0,0 +1,60 @@ +package service + +import ( + "errors" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt" + "hy2xs-admin/dao" + "hy2xs-admin/model/bo" + "hy2xs-admin/model/constant" + "strings" + "time" +) + +const TokenExpireDuration = time.Hour * 24 + +type MyClaims struct { + AccountBo bo.AccountBo `json:"account"` + jwt.StandardClaims +} + +func GenToken(accountBo bo.AccountBo) (string, error) { + c := MyClaims{ + AccountBo: accountBo, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(), + Issuer: "hy2xs-admin", + }, + } + config, err := dao.GetConfig("key = ?", constant.JwtSecret) + if err != nil { + return "", errors.New(constant.SysError) + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, c) + return token.SignedString([]byte(*config.Value)) +} + +func ParseToken(tokenString string) (*MyClaims, error) { + config, err := dao.GetConfig("key = ?", constant.JwtSecret) + if err != nil { + return nil, errors.New(constant.SysError) + } + token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (i interface{}, err error) { + return []byte(*config.Value), nil + }) + if err != nil { + return nil, errors.New(constant.IllegalTokenError) + } + if claims, ok := token.Claims.(*MyClaims); ok && token.Valid { + return claims, nil + } + return nil, errors.New(constant.TokenExpiredError) +} + +func GetToken(c *gin.Context) string { + tokenStr := c.Request.Header.Get("Authorization") + if tokenStr == "" { + return "" + } + return strings.SplitN(tokenStr, " ", 2)[1] +} diff --git a/apps/service/monitor.go b/apps/service/monitor.go new file mode 100644 index 0000000..345334b --- /dev/null +++ b/apps/service/monitor.go @@ -0,0 +1,64 @@ +package service + +import ( + "errors" + "fmt" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/vo" + "hy2xs-admin/util" + "regexp" + "strings" +) + +func MonitorSystem() (vo.SystemMonitorVo, error) { + cpuPercent, err := util.GetCpuPercent() + if err != nil { + return vo.SystemMonitorVo{}, errors.New("cpu query failed") + } + memPercent, err := util.GetMemPercent() + if err != nil { + return vo.SystemMonitorVo{}, errors.New("mem query failed") + } + diskPercent, err := util.GetDiskPercent() + if err != nil { + return vo.SystemMonitorVo{}, errors.New("disk query failed") + } + return vo.SystemMonitorVo{ + HUIVersion: constant.Version, + CpuPercent: cpuPercent, + MemPercent: memPercent, + DiskPercent: diskPercent, + }, nil +} + +func MonitorHysteria2() (vo.Hysteria2MonitorVo, error) { + var hysteria2MonitorVo vo.Hysteria2MonitorVo + onlineUsers, err := Hysteria2Online() + if err != nil { + return hysteria2MonitorVo, err + } + + if len(onlineUsers) > 0 { + hysteria2MonitorVo.UserTotal = int64(len(onlineUsers)) + var deviceTotal int64 = 0 + for _, value := range onlineUsers { + deviceTotal += value + } + hysteria2MonitorVo.DeviceTotal = deviceTotal + } + + hysteria2MonitorVo.Version = "-" + content, err := util.Exec(fmt.Sprintf("%s version", util.GetHysteria2BinPath())) + if err == nil { + pattern := `v\d+\.\d+\.\d+` + re := regexp.MustCompile(pattern) + matches := re.FindAllString(strings.TrimSpace(content), -1) + if len(matches) > 0 { + hysteria2MonitorVo.Version = matches[0] + } + } + + running := Hysteria2IsRunning() + hysteria2MonitorVo.Running = running + return hysteria2MonitorVo, nil +} diff --git a/apps/service/server.go b/apps/service/server.go new file mode 100644 index 0000000..7c22617 --- /dev/null +++ b/apps/service/server.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + "errors" + "fmt" + "github.com/sirupsen/logrus" + "hy2xs-admin/util" + "net/http" + "time" +) + +var server *http.Server + +func InitServer(addr string, handler http.Handler) { + server = &http.Server{ + Addr: addr, + Handler: handler, + } +} + +func StartServer(crtPath string, keyPath string) error { + if crtPath != "" && keyPath != "" { + return server.ListenAndServeTLS(crtPath, keyPath) + } + return server.ListenAndServe() +} + +func StopServer() error { + if err := StopHysteria2(); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + logrus.Errorf("failed to shutdown server: %v", err) + return errors.New("failed to shutdown server") + } + + return nil +} + +func GetServerPortAndCert() (int64, string, string, error) { + port, crtPath, keyPath, err := GetPortAndCert() + if err != nil { + return 0, "", "", err + } + + if !util.IsPortAvailable(uint(port), "tcp") { + errMsg := fmt.Sprintf("port %d is taken", port) + logrus.Errorf(errMsg) + return 0, "", "", errors.New(errMsg) + } + + if crtPath != "" && !util.Exists(crtPath) { + errMsg := fmt.Sprintf("crt path: %s does not exist", crtPath) + logrus.Errorf(errMsg) + return 0, "", "", errors.New(errMsg) + } + + if keyPath != "" && !util.Exists(keyPath) { + errMsg := fmt.Sprintf("key path: %s does not exist", keyPath) + logrus.Errorf(errMsg) + return 0, "", "", errors.New(errMsg) + } + + return port, crtPath, keyPath, nil +} diff --git a/apps/util/arr.go b/apps/util/arr.go new file mode 100644 index 0000000..a3c7cf0 --- /dev/null +++ b/apps/util/arr.go @@ -0,0 +1,32 @@ +package util + +func ArrContain[T comparable](arr []T, key T) bool { + for _, item := range arr { + if item == key { + return true + } + } + return false +} + +func SplitArr[T any](arr []T, num int) [][]T { + length := len(arr) + if length <= num { + return [][]T{arr} + } + + quantity := (length + num - 1) / num + segments := make([][]T, 0, quantity) + + for i := 0; i < quantity; i++ { + end := (i + 1) * num + if end > length { + end = length + } + + segment := arr[i*num : end] + segments = append(segments, segment) + } + + return segments +} diff --git a/apps/util/encrypt.go b/apps/util/encrypt.go new file mode 100644 index 0000000..7747158 --- /dev/null +++ b/apps/util/encrypt.go @@ -0,0 +1,17 @@ +package util + +import ( + "crypto/sha256" + "fmt" +) + +func SHA224String(password string) string { + hash := sha256.New224() + hash.Write([]byte(password)) + val := hash.Sum(nil) + str := "" + for _, v := range val { + str += fmt.Sprintf("%02x", v) + } + return str +} diff --git a/apps/util/encrypt_test.go b/apps/util/encrypt_test.go new file mode 100644 index 0000000..fb0f98d --- /dev/null +++ b/apps/util/encrypt_test.go @@ -0,0 +1,7 @@ +package util + +import "testing" + +func TestSHA224String(t *testing.T) { + println(SHA224String("sysadmin")) +} diff --git a/apps/util/export.go b/apps/util/export.go new file mode 100644 index 0000000..74da5e5 --- /dev/null +++ b/apps/util/export.go @@ -0,0 +1,40 @@ +package util + +import ( + "encoding/json" + "errors" + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + "hy2xs-admin/model/constant" + "os" +) + +// ExportFile t 0/json 1/yaml +func ExportFile(filePath string, data any, t int) error { + file, err := os.Create(filePath) + if err != nil { + logrus.Errorf("ExportFile create file err filePath: %s err: %v", filePath, err) + return errors.New(constant.SysError) + } + defer file.Close() + var bytes []byte + if t == 0 { + bytes, err = json.MarshalIndent(data, "", " ") + if err != nil { + logrus.Errorf("ExportFile Marshal json err filePath: %s err: %v", filePath, err) + return errors.New(constant.SysError) + } + } else if t == 1 { + bytes, err = yaml.Marshal(&data) + if err != nil { + logrus.Errorf("ExportFile Marshal yaml err filePath: %s err: %v", filePath, err) + return errors.New(constant.SysError) + } + } + _, err = file.Write(bytes) + if err != nil { + logrus.Errorf("ExportFile writer WriteString err filePath: %s err: %v", filePath, err) + return errors.New(constant.SysError) + } + return nil +} diff --git a/apps/util/file.go b/apps/util/file.go new file mode 100644 index 0000000..a6a79cc --- /dev/null +++ b/apps/util/file.go @@ -0,0 +1,88 @@ +package util + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" +) + +func Exists(path string) bool { + _, err := os.Stat(path) + if err != nil { + if os.IsExist(err) { + return true + } + return false + } + return true +} + +func RemoveFile(fileName string) error { + if Exists(fileName) { + if err := os.Remove(fileName); err != nil { + return errors.New("failed to delete file") + } + } + return nil +} + +// ReadLinesFromBottom Read the file contents sequentially from bottom to top and return the specified number of lines +func ReadLinesFromBottom(filePath string, numLines int) ([]string, int, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, 0, err + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + + // Read the file contents line by line and reverse the order of the lines + total := 0 + for scanner.Scan() { + lines = append(lines, scanner.Text()) + total++ + } + + if err := scanner.Err(); err != nil { + return nil, 0, err + } + + // Reverse row order + for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { + lines[i], lines[j] = lines[j], lines[i] + } + + // Returns the specified number of rows + if len(lines) < numLines { + numLines = len(lines) + } + return lines[:numLines], total, nil +} + +func FindFile(dir, filename string) (string, error) { + var result string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && info.Name() == filename { + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + result = absPath + return errors.New("file found") + } + return nil + }) + if err != nil && err.Error() != "file found" { + return "", err + } + if result == "" { + return "", fmt.Errorf("file %s not found in directory %s", filename, dir) + } + return result, nil +} diff --git a/apps/util/github.go b/apps/util/github.go new file mode 100644 index 0000000..7f78afc --- /dev/null +++ b/apps/util/github.go @@ -0,0 +1,57 @@ +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 +} diff --git a/apps/util/hysteria2.go b/apps/util/hysteria2.go new file mode 100644 index 0000000..374bf30 --- /dev/null +++ b/apps/util/hysteria2.go @@ -0,0 +1,65 @@ +package util + +import ( + "fmt" + "hy2xs-admin/model/constant" + "io" + "net/http" + "os" + "runtime" +) + +func GetHysteria2BinPath() string { + 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 +} diff --git a/apps/util/linux.go b/apps/util/linux.go new file mode 100644 index 0000000..8db725d --- /dev/null +++ b/apps/util/linux.go @@ -0,0 +1,100 @@ +package util + +import ( + "errors" + "fmt" + "github.com/shirou/gopsutil/cpu" + "github.com/shirou/gopsutil/disk" + "github.com/shirou/gopsutil/mem" + "github.com/sirupsen/logrus" + "net" + "os" + "os/exec" + "strconv" + "time" +) + +func Exec(cmd string) (string, error) { + command := exec.Command("bash", "-c", cmd) + command.Env = os.Environ() + output, err := command.CombinedOutput() + if err != nil { + logrus.Errorf("execute command failed cmd: %s err: %v", cmd, err) + return "", fmt.Errorf("execute command failed cmd: %s", cmd) + } + return string(output), nil +} + +func Systemctl(action string, unit string) error { + _, err := Exec(fmt.Sprintf("systemctl %s %s", action, unit)) + return err +} + +func IsPortAvailable(port uint, network string) bool { + if network == "tcp" { + listener, err := net.ListenTCP(network, &net.TCPAddr{ + IP: net.IPv4(0, 0, 0, 0), + Port: int(port), + }) + defer func() { + if listener != nil { + listener.Close() + } + }() + if err != nil { + logrus.Errorf("port %d is taken err: %s", port, err) + return false + } + } + if network == "udp" { + listener, err := net.ListenUDP("udp", &net.UDPAddr{ + IP: net.IPv4(0, 0, 0, 0), + Port: int(port), + }) + defer func() { + if listener != nil { + listener.Close() + } + }() + if err != nil { + logrus.Errorf("port %d is taken err: %s", port, err) + return false + } + } + return true +} + +func GetCpuPercent() (float64, error) { + var err error + percent, err := cpu.Percent(time.Second, false) + value, err := strconv.ParseFloat(fmt.Sprintf("%.1f", percent[0]), 64) + return value, err +} + +func GetMemPercent() (float64, error) { + var err error + memInfo, err := mem.VirtualMemory() + value, err := strconv.ParseFloat(fmt.Sprintf("%.1f", memInfo.UsedPercent), 64) + return value, err +} + +func GetDiskPercent() (float64, error) { + var err error + parts, err := disk.Partitions(true) + diskInfo, err := disk.Usage(parts[0].Mountpoint) + value, err := strconv.ParseFloat(fmt.Sprintf("%.1f", diskInfo.UsedPercent), 64) + return value, err +} + +func VerifyPort(port string) error { + if port != "" { + value, err := strconv.ParseInt(port, 10, 64) + if err != nil { + return errors.New("invalid port value") + } + if value <= 0 || value > 65535 { + return errors.New("the port range is between 0-65535") + } + } + return nil +} diff --git a/apps/util/map.go b/apps/util/map.go new file mode 100644 index 0000000..9b86a32 --- /dev/null +++ b/apps/util/map.go @@ -0,0 +1,23 @@ +package util + +func SplitMap[T any](inputMap map[string]T, chunkSize int) []map[string]T { + length := len(inputMap) + quantity := (length + chunkSize - 1) / chunkSize + segments := make([]map[string]T, 0, quantity) + + var groupIndex int + currentGroup := make(map[string]T) + + for key, value := range inputMap { + currentGroup[key] = value + + if len(currentGroup) == chunkSize || groupIndex+1 == quantity { + // When the current group is full or the last group is reached, add the mapping to the result slice + segments = append(segments, currentGroup) + currentGroup = make(map[string]T) // Initialize a new mapping + groupIndex++ + } + } + + return segments +} diff --git a/apps/util/rand.go b/apps/util/rand.go new file mode 100644 index 0000000..00bc91d --- /dev/null +++ b/apps/util/rand.go @@ -0,0 +1,19 @@ +package util + +import "crypto/rand" + +const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func RandomString(length int) (string, error) { + bytes := make([]byte, length) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + + for i := range bytes { + bytes[i] = charset[int(bytes[i])%len(charset)] + } + + return string(bytes), nil +} diff --git a/apps/util/string.go b/apps/util/string.go new file mode 100644 index 0000000..2a145a1 --- /dev/null +++ b/apps/util/string.go @@ -0,0 +1,36 @@ +package util + +import "strings" + +func CompareVersion(version1, version2 string) int { + v1 := strings.Split(version1, ".") + v2 := strings.Split(version2, ".") + + // Compare major version numbers + if v1[0] > v2[0] { + return 1 + } else if v1[0] < v2[0] { + return -1 + } + + // If the major version numbers are the same, compare the minor version numbers + if len(v1) > 1 && len(v2) > 1 { + if v1[1] > v2[1] { + return 1 + } else if v1[1] < v2[1] { + return -1 + } + } + + // If the major and minor versions are the same, compare the revision numbers + if len(v1) > 2 && len(v2) > 2 { + if v1[2] > v2[2] { + return 1 + } else if v1[2] < v2[2] { + return -1 + } + } + + // The version number is exactly the same + return 0 +} diff --git a/docs/01-architecture-baseline.md b/docs/01-architecture-baseline.md new file mode 100644 index 0000000..5793678 --- /dev/null +++ b/docs/01-architecture-baseline.md @@ -0,0 +1,127 @@ +# Architecture baseline + +## Цель + +Зафиксировать одну непротиворечивую схему без смешивания локальной сборки, серверной установки и внешнего access layer. + +## Два слоя системы + +### 1. Builder layer +Запускается **только локально**, на отдельной машине разработчика / оператора. + +Функции: +- хранение исходников проекта +- хранение и сопровождение **нашего форка HY2XS admin** +- хранение исходников оркестратора на **Bun + TypeScript** +- компиляция install-артефакта оркестратора +- подготовка install package +- упаковка unit-файлов, шаблонов конфигов и документации +- контроль версии проекта как целого + +Builder layer **не разворачивается на сервере**. + +### 2. Runtime / target layer +Запускается **только на чистом Debian 12**. + +Функции: +- установка системных зависимостей +- разворачивание файлов пакета +- скачивание **свежей Hysteria2 из официального upstream** +- создание server config +- установка и запуск **встроенного HY2XS admin** +- создание systemd unit-файлов +- применение nftables baseline +- создание `post-install.env` + +Target layer **не содержит сборщика** и **не выполняет target-side build**. + +## Компоненты baseline + +### Серверный транспорт +- **Hysteria2** +- QUIC/UDP +- один фиксированный UDP-порт +- `Salamander` включён по умолчанию +- IPv4-only +- лимит по умолчанию: 50/50 Mbps на клиента + +### UI слой +- **наш форк H UI / HY2XS admin** +- поставляется внутри проекта +- устанавливается локально из итогового пакета +- не скачивается с upstream на сервере + +### Orchestrator слой +- **Bun + TypeScript** +- собирается локально builder layer'ом +- попадает на target как готовый install-артефакт +- не требует `npm/pnpm/yarn/bun install` на сервере + +### Server ops слой +- systemd +- nftables +- `post-install.env` + +## Принципы + +### 1. Ядро, UI и оркестратор ведут себя по-разному +- Hysteria2: берём свежую upstream-версию при установке +- HY2XS admin: держим **свой fork** и поставляем его сами +- Оркестратор: пишем на **Bun + TypeScript**, но собираем **локально**, а не на target + +### 2. Builder и target не смешиваются +Сборка — локально. +Установка — на сервере. +На сервере не должно быть логики «собери мне UI» или «собери мне TypeScript оркестратор». + +### 3. Оркестратор install-only +Оркестратор умеет только: +- установить +- разложить файлы +- создать базовую конфигурацию +- подготовить сервер к работе + +Он **не** умеет: +- обновлять уже установленную систему +- откатывать версии +- удалять установку +- чинить неизвестные поломанные старые состояния + +### 4. Access layer вынесен за рамки baseline +Telegram-бот, backend выдачи ключей, remote profile publishing, billing и похожие пользовательские контуры не входят в этот baseline. + +## Что входит в baseline + +1. local builder +2. install package +3. vanilla Hysteria2 from upstream +4. bundled HY2XS admin +5. Bun/TypeScript install-only orchestrator +6. systemd + nftables +7. post-install env +8. install-only flow под чистый Debian 12 + +## Что не входит в baseline + +- target-side builder +- target-side git clone нашего UI +- target-side `bun install` / transpile / compile +- Telegram-бот +- backend выдачи remote profiles +- update / rollback / uninstall +- Docker как основной способ поставки +- multi-node deployment +- сложный control plane + +## Финальный результат + +Правильный baseline-результат выглядит так: + +1. На локальной машине собирается install package. +2. В пакет уже встроены наш HY2XS admin и install-артефакт оркестратора. +3. Пакет переносится на чистый Debian 12. +4. На сервере запускается только install-only orchestration. +5. Сервер скачивает свежую Hysteria2 из official upstream. +6. Сервер разворачивает bundled UI из пакета. +7. Создаются systemd unit-файлы, firewall baseline и `post-install.env`. +8. Сервер готов как базовое рабочее окружение HY2XS. diff --git a/docs/02-build-layer-and-package.md b/docs/02-build-layer-and-package.md new file mode 100644 index 0000000..6613464 --- /dev/null +++ b/docs/02-build-layer-and-package.md @@ -0,0 +1,134 @@ +# Build layer and package + +## Цель документа + +Зафиксировать локальный слой сборки и формат итогового install package. + +## Базовое решение + +В baseline builder остаётся **shell-first** для packaging-слоя. + +То есть: +- основной packaging pipeline — **sh/bash** +- оркестратор при этом пишется на **Bun + TypeScript** +- builder локально компилирует оркестратор в готовый install-артефакт +- target machine не должна сама собирать или доустанавливать JS/TS toolchain + +Причина простая: packaging можно держать простым, а оркестратор — typed и модульным. + +## Где работает builder + +Production builder работает на отдельном build host: +- Debian 12 +- amd64 / x86_64 +- bash +- доступ к интернету для apt и скачивания toolchain + +В текущей production-модели сборка выполняется **на Debian 12 amd64**, а не на Windows/macOS dev-машине. + +Builder не является частью target install flow: на target server приезжает уже готовый install package, без JS/TS/Go build step. + +## Что хранится в репозитории проекта + +Минимум: +- исходники оркестратора на **Bun + TypeScript** +- shell packaging scripts +- шаблоны конфигов +- systemd unit templates +- docs +- **наш fork HY2XS admin** +- шаблоны для `post-install.env` +- package metadata + +## Что делает builder + +1. Проверяет структуру проекта. +2. Собирает / подготавливает HY2XS admin. +3. Компилирует оркестратор из Bun/TypeScript в install-артефакт. +4. Копирует артефакты UI в package staging directory. +5. Кладёт entrypoint, templates, docs и service files. +6. Формирует итоговый install package. +7. При необходимости считает manifest/checksum. +8. Выдаёт один переносимый результат для target machine. + +## Что builder не делает + +- не ставит Hysteria2 на локальной машине «для продакшена» +- не превращается в CI/CD платформу +- не генерирует update pipeline +- не делает uninstall manifests +- не готовит миграции между старыми инсталляциями + +## Рекомендуемая структура + +```text +project/ +├── tools/ +│ └── build/ +│ ├── build.sh +│ ├── README.ru.md +│ └── lib/ +├── orchestrator/ +│ ├── package.json +│ ├── bun.lock +│ ├── tsconfig.json +│ └── src/ +├── package/ +│ ├── install.sh +│ ├── orchestrator/ +│ ├── templates/ +│ └── systemd/ +├── ui/ +│ └── hy2xs-admin-fork/ +├── docs/ +└── dist/ +``` + +## Формат итогового пакета + +Итоговый пакет должен содержать: + +- install-only orchestrator artifact +- bundled HY2XS admin +- unit templates +- config templates +- docs / examples +- manifest версии проекта + +Итоговый пакет **не должен** содержать: +- builder scripts +- исходную локальную build-среду +- временные каталоги сборки +- мусор CI +- target-side dependency install step для оркестратора + +## Production builder bootstrap + +`tools/build/build.sh` должен быть самодостаточным для Debian 12 amd64: + +1. Проверяет ОС и архитектуру. +2. Проверяет структуру репозитория и lock-файлы. +3. Доставляет отсутствующие системные build-зависимости через `apt-get`. +4. Проверяет версии Go, Bun, Node.js и pnpm. +5. При несовпадении версий скачивает управляемый локальный toolchain в `.toolchain/`. +6. Собирает только Linux amd64 артефакты. +7. Записывает версии toolchain в metadata пакета. + +## Отношение к Hysteria2 + +Сам бинарь Hysteria2 **не вендорится** в install package как baseline-правило. + +Причина: +- ядро Hysteria рассматривается как stable upstream component +- целевая установка должна брать его с official upstream на момент развёртывания + +## Инварианты + +Система считается правильной, если: + +1. builder запускается на Debian 12 amd64 build host, не как target-side build step +2. пакет можно перенести на чистый Debian 12 +3. на сервере нет отдельного build step +4. bundled UI уже находится внутри пакета +5. оркестратор authored as Bun/TypeScript, но на target приходит как готовый install-артефакт +6. Hysteria2 подтягивается install layer'ом с upstream, а не собирается на target из исходников diff --git a/docs/03-server-hysteria2.md b/docs/03-server-hysteria2.md new file mode 100644 index 0000000..b0c765c --- /dev/null +++ b/docs/03-server-hysteria2.md @@ -0,0 +1,103 @@ +# Server Hysteria2 baseline + +## Цель документа + +Зафиксировать правила для серверного слоя Hysteria2 в модели, где UI поставляется вместе с проектом, а Hysteria берётся из official upstream во время установки. + +## Роль Hysteria2 + +Hysteria2 — основной транспортный компонент сервера. +Он не форкается и не поставляется как часть UI-форка. + +## Source policy + +Базовое правило: +- Hysteria2 скачивается **во время установки** +- источник — **официальный upstream** +- install layer не должен подменять собой upstream-дистрибуцию Hysteria2 + +## Версионная политика + +С учётом выбранной модели «берём свежее из upstream» фиксируется такая практика: + +- по умолчанию install layer тянет **свежий upstream release / install source** +- фактически установленная версия обязательно записывается в `post-install.env` +- документация не обещает жёсткий pin как baseline +- если оператору нужна строгая фиксация версии, это отдельный режим, а не базовая модель + +## Платформа + +- ОС: только Debian 12 +- init/system management: systemd +- сетевой фильтр: nftables +- архитектура baseline: x86_64/amd64 + +## Listen и сеть + +### Listen +- только IPv4 +- формат: `0.0.0.0:` + +### Порт +- один фиксированный UDP-порт +- этот порт должен совпадать в: + - server config + - firewall rules + - `post-install.env` + +## Обфускация + +В baseline включается: +- `obfs.type: salamander` +- `obfs.password` + +Правила: +- пароль должен быть сильным +- пароль должен фиксироваться в конфигурационном контуре +- значение должно быть доступно оператору через runtime config и `post-install.env` + +## TLS + +Требования: +- нормальный домен +- корректный `server_name` / SNI на клиентах +- одна понятная TLS policy +- без смешивания нескольких несовместимых схем по умолчанию + +## Auth policy + +Для baseline выбирается одна предсказуемая auth-модель. + +Правила: +- install flow должен оставить рабочий auth state +- bootstrap auth material должен быть либо передан оператором, либо безопасно сгенерирован +- дальнейшая модель выдачи доступа пользователям не фиксируется в этом пакете docs + +## Bandwidth и congestion + +Серверная baseline policy: +- `bandwidth.up = 50 mbps` +- `bandwidth.down = 50 mbps` +- `ignoreClientBandwidth = false` + +Важно: +- эти параметры сами по себе не исчерпывают speed policy +- корректный лимит ожидается только в паре с совместимым клиентским конфигом + +## Рекомендуемые пути + +- `/etc/hysteria/config.yaml` +- `/var/lib/hysteria/` +- `/etc/hysteria/post-install.env` + +## Серверные инварианты + +После установки должно быть верно: + +1. Hysteria2 получена из official upstream +2. фактическая версия отражена в `post-install.env` +3. конфиг валиден +4. сервис стартует через systemd +5. нужный UDP-порт реально слушается +6. тестовый совместимый клиент может подключиться +7. bundled UI работает поверх актуального состояния сервера diff --git a/docs/04-admin-panel-h-ui-fork.md b/docs/04-admin-panel-h-ui-fork.md new file mode 100644 index 0000000..02ead90 --- /dev/null +++ b/docs/04-admin-panel-h-ui-fork.md @@ -0,0 +1,108 @@ +# Admin panel: bundled H UI fork + +## Цель документа + +Зафиксировать новую модель работы с UI: панель больше не рассматривается как внешний upstream-зависимый слой для target install, а становится **нашим вендорным компонентом**, поставляемым вместе с проектом. + +## Почему меняем подход + +Причина архитектурная: + +- Hysteria2 ядро считаем достаточно стабильным upstream-компонентом +- UI считаем более слабым по поддержке и менее надёжным как внешний operational dependency +- поэтому UI забираем к себе: **fork + vendor + ship with package** + +## Что это означает практически + +### Было +- Hysteria — upstream +- H UI — отдельный upstream +- оркестратор ставит оба компонента как внешние зависимости + +### Стало +- Hysteria — upstream +- H UI — **наш fork внутри проекта** +- итоговый package уже содержит UI +- на target server не надо скачивать H UI из чужого репозитория + +## Правильная модель + +- локальный builder хранит и собирает наш fork H UI +- install package везёт UI на сервер +- target-side orchestrator только раскладывает UI и создаёт unit +- H UI продолжает работать как надстройка над Hysteria YAML/API-слоем + +## Что считать нормальным + +Факт, что H UI — это надстройка над YAML-конфигом Hysteria, считается нормальным. +Это не аргумент против использования UI. +Важно только, чтобы источник истины по runtime-состоянию был понятен и не было двух конкурирующих конфигурационных миров без правил синхронизации. + +## Scope панели + +Панель нужна для: +- operator-facing управления +- просмотра статуса +- работы с пользователями / трафиком / сущностями доступа +- удобной админской рутины + +Панель не должна: +- определять install lifecycle сервера +- превращать систему в сложный control plane +- диктовать scope оркестратора + +## Правила поставки + +Bundled H UI должна: +- поставляться внутри итогового пакета +- иметь свой install dir +- иметь свой data dir +- запускаться отдельным systemd unit +- не требовать target-side build + +## Правила ответственности + +### Source of truth +- runtime transport layer: Hysteria +- операторский UI layer: forked H UI +- install lifecycle: наш orchestrator +- deploy facts: `post-install.env` + +## Production lifecycle Hysteria2 + +В production package HY2XS admin не скачивает и не обновляет бинарь Hysteria2 самостоятельно. + +Правильная модель: +- Hysteria2 устанавливается install-оркестратором с official upstream +- Hysteria2 запускается отдельным `hysteria-server.service` +- HY2XS admin работает как operator UI и HTTP auth/traffic layer +- смена версии Hysteria2 через UI отключена в baseline +- список upstream releases не является частью operator UI baseline + +### Что нельзя делать +- скачивать H UI с upstream прямо на target как baseline +- собирать UI на сервере +- склеивать unit Hysteria и unit H UI в один сервис +- раздувать оркестратор из-за особенностей UI +- использовать HY2XS admin как updater бинаря Hysteria2 + +## Что фиксировать в `post-install.env` + +Минимум: +- `HUI_ENABLED` +- `HUI_FORK_REF` +- `HUI_BUILD_ID` +- `HUI_BIND_HOST` +- `HUI_PORT` +- `HUI_INSTALL_DIR` +- `HUI_DATA_DIR` + +## Инварианты + +Схема считается корректной, если: + +1. UI приезжает на target уже в составе пакета +2. target не скачивает UI с внешнего upstream +3. UI работает отдельным сервисом +4. UI не меняет install-only scope оркестратора +5. Hysteria остаётся внешним vanilla upstream-компонентом diff --git a/docs/05-client-and-access-scope.md b/docs/05-client-and-access-scope.md new file mode 100644 index 0000000..fd4d20c --- /dev/null +++ b/docs/05-client-and-access-scope.md @@ -0,0 +1,46 @@ +# Client and access scope + +## Цель документа + +Зафиксировать, что клиентский delivery/access layer не является частью install baseline. + +## Что входит в baseline + +В baseline этого пакета docs входит только следующее: +- установка Hysteria2 +- установка HY2XS admin +- базовая настройка systemd / firewall / `post-install.env` +- подготовка рабочего серверного окружения + +## Что не входит в baseline + +В baseline **не входят**: +- Telegram-бот +- backend выдачи профилей +- remote profile publishing +- deep links +- billing / подписки / тарифные планы +- отдельный user-access API + +## Что допускается как вспомогательный слой + +Для smoke/manual testing могут существовать: +- тестовый клиентский конфиг +- тестовый URI +- отдельные примеры импортируемых клиентских артефактов + +Но это не делает access layer частью install baseline. + +## Почему это важно + +Если смешать install baseline и delivery layer, документация начинает неверно обещать лишнее: +- будто оркестратор обязан выдавать ключи пользователям +- будто сервер после установки автоматически включает пользовательский backend +- будто Telegram-бот является обязательной частью системы + +Это неверно. + +## Правильная формулировка + +После выполнения install flow система должна быть готова как серверное окружение HY2XS. +Как именно оператор потом выдаёт доступ клиентам — отдельный продуктовый контур и отдельная документация. diff --git a/docs/06-speed-limits-and-congestion.md b/docs/06-speed-limits-and-congestion.md new file mode 100644 index 0000000..28cdbf3 --- /dev/null +++ b/docs/06-speed-limits-and-congestion.md @@ -0,0 +1,56 @@ +# Speed limits and congestion policy + +## Цель документа + +Зафиксировать корректную speed policy без неточных упрощений. + +## Что нельзя считать правильной схемой + +Нельзя описывать baseline так: +- на сервере включили host BBR +- выдали какой-то URI +- автоматически получили строгий лимит 50 Mbps на клиента + +Это неверная модель. + +## Что зафиксировано в baseline + +### На сервере +- `bandwidth.up = 50 mbps` +- `bandwidth.down = 50 mbps` +- `ignoreClientBandwidth = false` + +### На клиенте +Совместимый клиентский конфиг должен задавать соответствующие bandwidth hints: +- `up_mbps = 50` +- `down_mbps = 50` + +## Практический смысл + +Ожидаемый 50/50 Mbps contract считается корректным только тогда, когда сервер и клиентская конфигурация согласованы. + +## Что делать с host-level BBR + +`net.ipv4.tcp_congestion_control=bbr` можно оставить как общий системный тюнинг, но: + +- это не главный механизм speed policy Hysteria2 +- это не замена клиентским bandwidth hints +- это не центр документации по лимитам + +## Что фиксировать в `post-install.env` + +Минимум: +- `HY2_BANDWIDTH_UP_Mbps` +- `HY2_BANDWIDTH_DOWN_Mbps` +- `HY2_IGNORE_CLIENT_BANDWIDTH` + +## Что нельзя писать в проектных доках + +Не писать: +- «лимит задаётся только на сервере, клиент не важен» +- «любой URI достаточно для полной speed policy» +- «host BBR и есть логика Hysteria» + +## Правильная baseline-формулировка + +Пер-клиентный лимит 50/50 Mbps обеспечивается согласованной серверной и клиентской конфигурацией. Install baseline отвечает за серверную часть этого контракта; конкретный delivery/access слой в этот документ не входит. diff --git a/docs/07-systemd-and-firewall.md b/docs/07-systemd-and-firewall.md new file mode 100644 index 0000000..7d97e04 --- /dev/null +++ b/docs/07-systemd-and-firewall.md @@ -0,0 +1,62 @@ +# systemd and firewall + +## Цель документа + +Зафиксировать базовый systemd/firewall слой под новую install model. + +## systemd: Hysteria2 + +Базовые требования: +- отдельный unit `hysteria-server.service` +- отдельный пользователь `hysteria` +- автозапуск после reboot +- restart policy для падений + +Базовый ExecStart: +```bash +/usr/local/bin/hysteria server -c /etc/hysteria/config.yaml +``` + +## systemd: HY2XS admin + +Базовые требования: +- отдельный unit `hy2xs-admin.service` +- отдельный install dir +- отдельный data dir +- отдельный жизненный цикл от Hysteria + +Важно: +- HY2XS admin не должен запускаться как часть unit Hysteria +- unit-файлы не должны быть склеены + +## Базовая firewall-модель + +Нужно разрешить: +- UDP-порт Hysteria2 +- TCP-порт SSH +- established/related traffic + +После staged-проверки можно включать default policy `drop`. + +## Порядок применения + +1. Добавить allow-правила. +2. Проверить, что SSH-сессия не теряется. +3. Проверить listen Hysteria-порта. +4. Только потом затягивать policy. + +## Что не делаем + +В baseline не делаем: +- port hopping +- сложную динамическую firewall-логику +- смешение UI-портов и публичного транспортного порта в один firewall-контур без правил + +## Инварианты + +Система считается корректной, если: +1. Hysteria и HY2XS admin работают отдельными systemd unit +2. Hysteria слушает нужный UDP-порт +3. SSH не ломается после применения firewall +4. firewall-политика не противоречит listen policy +5. после reboot оба нужных сервиса стартуют корректно diff --git a/docs/08-orchestrator-spec.md b/docs/08-orchestrator-spec.md new file mode 100644 index 0000000..97c655b --- /dev/null +++ b/docs/08-orchestrator-spec.md @@ -0,0 +1,131 @@ +# Install-only orchestrator spec + +## Цель документа + +Зафиксировать ТЗ на оркестратор с учётом двухслойной архитектуры: builder отдельно, target install отдельно. + +## Технологический стек оркестратора + +Оркестратор фиксируется как: +- **Bun + TypeScript** по исходникам +- локальная сборка builder layer'ом +- поставка на target в виде **готового install-артефакта** + +Это означает: +- на target нет `npm`, `pnpm`, `yarn` или `bun install` +- на target нет transpile/build step +- shell на target допустим только как thin wrapper entrypoint + +## Главная роль оркестратора + +Оркестратор работает **только на target machine** и умеет только: +- выполнить первичную установку +- разложить bundled UI +- скачать Hysteria2 из official upstream +- создать базовые конфиги +- создать unit-файлы +- применить baseline firewall +- создать `post-install.env` + +## Оркестратор не умеет + +- upgrade +- rollback +- uninstall +- repair старых неизвестных состояний +- target-side build +- target-side git clone нашего UI-форка +- Telegram-бот / access delivery + +## Предусловия + +Оркестратор рассчитан только на: +- чистый Debian 12 +- root/sudo install context +- один сервер +- одну baseline-схему + +Если машина уже «жила своей жизнью», baseline не обещает корректной автоадаптации. + +## Что приходит на target + +На target должен попадать уже готовый package, содержащий: +- thin install entrypoint +- compiled orchestrator artifact +- bundled HY2XS admin +- templates +- unit files +- docs/examples +- metadata package version / build id + +## Логическая модульность + +Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам: +- preflight +- deps +- filesystem +- hysteria +- ui +- systemd +- firewall +- env +- smoke + +## Что делает оркестратор по шагам + +1. Проверяет, что ОС — Debian 12. +2. Проверяет базовые зависимости и install context. +3. Создаёт каталоги установки. +4. Разворачивает bundled HY2XS admin. +5. Скачивает Hysteria2 из official upstream. +6. Генерирует Hysteria config. +7. Создаёт systemd unit для Hysteria. +8. Создаёт systemd unit для HY2XS admin. +9. Применяет nftables baseline. +10. Создаёт `post-install.env`. +11. Запускает сервисы и выполняет smoke-check. + +## Модель поставки + +Рекомендуемая baseline-модель: +- исходники оркестратора хранятся в `orchestrator/` +- builder выполняет локальную сборку через Bun +- в install package кладётся готовый артефакт, который запускается thin wrapper'ом + +Например: +- `package/install.sh` — проверка контекста и вызов оркестратора +- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт + +## Логирование и коды возврата + +Оркестратор должен: +- печатать понятные step-based сообщения +- завершаться ненулевым кодом при ошибке +- не скрывать первичный источник падения +- разделять preflight/config/runtime ошибки хотя бы на уровне текста + +## Политика ошибок + +- Любой конфликт неизвестного старого состояния = stop with error. +- Никакой сложной автомиграции. +- Ошибки должны быть текстовыми и пригодными для диагностики. + +## CLI baseline + +Допустимые флаги: +- `--non-interactive` +- `--domain` +- `--port` +- `--ssh-port` +- `--skip-firewall` +- `--skip-start` +- `--ui-port` +- `--ui-bind-host` + +## Что не реализовывать + +- update subcommands +- rollback subcommands +- uninstall subcommands +- reconcile logic +- выдачу пользовательских ключей или bot workflow diff --git a/docs/09-post-install-env.md b/docs/09-post-install-env.md new file mode 100644 index 0000000..0b709d0 --- /dev/null +++ b/docs/09-post-install-env.md @@ -0,0 +1,92 @@ +# Post-install env + +## Цель документа + +Зафиксировать `post-install.env` как компактный deploy reference file после первичной установки. + +## Зачем нужен файл + +После первичной установки оператору нужна одна точка, где видно: +- какой пакет был установлен +- какой build артефакт использован +- какой стек оркестратора применён +- какая версия Hysteria реально установилась +- какой fork/build UI разложен на target +- какие базовые параметры сети и портов заданы + +Именно для этого создаётся `post-install.env`. + +## Чего файл не делает + +Этот файл: +- не делает оркестратор update-manager'ом +- не гарантирует автоматическое применение изменений +- не заменяет runtime-конфиги +- не превращает target в builder + +## Рекомендуемый путь + +```bash +/etc/hysteria/post-install.env +``` + +## Минимальный набор переменных + +### Deploy / package +- `DEPLOY_TARGET_OS` +- `DEPLOY_TIMESTAMP` +- `PACKAGE_NAME` +- `PACKAGE_BUILD_ID` +- `PACKAGE_VERSION` + +### Orchestrator +- `ORCH_SOURCE_STACK=bun-typescript` +- `ORCH_BUILD_MODE` +- `ORCH_BUILD_ID` +- `ORCH_ENTRYPOINT` + +### Общие +- `DEPLOY_DOMAIN` +- `SSH_PORT` + +### Hysteria +- `HY2_SOURCE=official-upstream` +- `HY2_VERSION` +- `HY2_LISTEN_HOST` +- `HY2_PORT` +- `HY2_AUTH_MODE` +- `HY2_AUTH_URL` +- `HY2_TRAFFIC_STATS_LISTEN` +- `HY2_OBFS_TYPE` +- `HY2_OBFS_PASSWORD` +- `HY2_BANDWIDTH_UP_Mbps` +- `HY2_BANDWIDTH_DOWN_Mbps` +- `HY2_IGNORE_CLIENT_BANDWIDTH` +- `HY2_CONFIG_PATH` + +### HY2XS admin +- `HUI_ENABLED` +- `HUI_FORK_REF` +- `HUI_BUILD_ID` +- `HUI_BIND_HOST` +- `HUI_PORT` +- `HUI_INSTALL_DIR` +- `HUI_DATA_DIR` + +## Как работать с файлом + +Правильная модель: +1. оркестратор создаёт файл при первичной установке +2. оператор использует файл как reference/source-of-truth +3. при необходимости оператор вручную переносит изменения в реальные рабочие конфиги +4. затем оператор применяет изменения документированным способом + +## Что нельзя делать + +- сваливать туда временный мусор +- считать, что edit env автоматически меняет runtime +- использовать файл как замену настоящей конфигурации сервисов + +## Пример + +См. [examples/post-install.env.example](examples/post-install.env.example). diff --git a/docs/10-access-layer-out-of-scope.md b/docs/10-access-layer-out-of-scope.md new file mode 100644 index 0000000..a6385a2 --- /dev/null +++ b/docs/10-access-layer-out-of-scope.md @@ -0,0 +1,41 @@ +# Access layer out of scope + +## Цель документа + +Явно зафиксировать, что пользовательский access/delivery слой не является частью этого baseline-пакета. + +## Что не надо обещать в этих доках + +Нельзя описывать систему так, будто install-only оркестратор также отвечает за: +- Telegram-бота +- выдачу ключей пользователям +- backend профилей +- remote profile publishing +- deep link delivery +- billing или управление подписками + +Это отдельные контуры. + +## Что реально делает baseline + +Baseline делает только следующее: +- устанавливает Hysteria2 +- устанавливает HY2XS admin +- создаёт runtime-конфиги +- создаёт systemd units +- применяет firewall baseline +- фиксирует deploy facts в `post-install.env` + +## Что может существовать рядом, но отдельно + +Отдельно от install baseline могут существовать: +- клиентские инструкции +- тестовые конфиги +- access backend +- бот/панель/CRM/ERP-логика выдачи доступа + +Но это требует отдельной документации и отдельного scope. + +## Итоговая формулировка + +HY2XS baseline в этих документах — это **оркестратор установки и базовой серверной конфигурации**, а не пользовательский delivery platform. diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md new file mode 100644 index 0000000..54ad7ca --- /dev/null +++ b/docs/11-testing-and-acceptance.md @@ -0,0 +1,66 @@ +# Testing and acceptance + +## Цель документа + +Зафиксировать checklist для новой двухслойной схемы. + +## A. Builder layer tests + +### Проверяем +1. builder запускается на Debian 12 amd64 build host +2. итоговый пакет собирается без target-side шагов +3. bundled HY2XS admin реально входит в пакет +4. package metadata / build id присутствуют +5. compiled Bun/TypeScript orchestrator artifact присутствует +6. в пакет не попадает build-мусор +7. builder сам доставляет отсутствующие build-зависимости +8. builder проверяет версии Go/Bun/Node.js/pnpm +9. builder пишет версии toolchain в metadata + +## B. Target install tests + +### На чистом Debian 12 проверяем +1. пакет запускается без ручной сборки на сервере +2. Hysteria2 скачивается с official upstream +3. bundled HY2XS admin раскладывается локально из пакета +4. создаются нужные каталоги +5. создаются systemd unit-файлы +6. создаётся `post-install.env` +7. baseline firewall применяется корректно +8. SSH остаётся доступным + +## C. Runtime tests + +1. `hysteria-server` active +2. `hy2xs-admin` active +3. UDP-порт слушается +4. HY2XS admin открывается по ожидаемому admin path +5. тестовый совместимый клиент подключается +6. идёт реальный трафик +7. лимит 50/50 Mbps соблюдается при согласованной клиентской конфигурации +8. reboot не ломает baseline +9. Hysteria2 управляется systemd unit, а не внутренним updater'ом admin panel + +## D. Negative tests + +1. не Debian 12 +2. порт уже занят +3. старое конфликтующее состояние уже существует +4. домен / SNI заданы некорректно +5. bundled UI отсутствует в пакете +6. Hysteria upstream недоступен +7. firewall применился частично +8. install flow прерван посередине + +## Acceptance criteria + +Система принимается, если: + +1. production builder на Debian 12 amd64 выдаёт переносимый install package +2. target server не выполняет build step +3. Hysteria2 получена из official upstream +4. UI поставлен из bundled fork +5. `post-install.env` отражает фактическое deploy-состояние +6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт +7. оркестратор не требует update / rollback / uninstall логики +8. Telegram/access layer не требуется для прохождения install acceptance diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md new file mode 100644 index 0000000..30080d4 --- /dev/null +++ b/docs/12-operations-and-troubleshooting.md @@ -0,0 +1,94 @@ +# Operations and troubleshooting + +## Цель документа + +Зафиксировать минимальный operational контур после установки. + +## Что должен помнить оператор + +### 1. Builder и target — разные миры +Если нужно изменить состав install package, это делается в локальном builder layer, а не на target server. + +### 2. UI приезжает из нашего пакета +Если проблема в UI, сначала смотреть: +- какой `HUI_FORK_REF` +- какой `HUI_BUILD_ID` +- тот ли пакет вообще стоит на сервере + +### 3. Hysteria приходит из upstream +Если проблема в ядре Hysteria, сначала смотреть: +- какую фактическую версию оркестратор установил +- что записано в `HY2_VERSION` +- не связано ли поведение со свежим upstream release + +### 4. Оркестратор — Bun/TypeScript, но target не билдит его +Если проблема в install flow, сначала смотреть: +- какой `ORCH_BUILD_ID` +- какой `ORCH_ENTRYPOINT` +- не подменён ли install package вручную + +## Базовые команды проверки + +Проверка сервисов: +```bash +systemctl status hysteria-server +systemctl status hy2xs-admin +``` + +Проверка порта: +```bash +ss -uln +``` + +Проверка firewall: +```bash +nft list ruleset +``` + +Проверка `post-install.env`: +```bash +cat /etc/hysteria/post-install.env +``` + +## Типовые проблемы + +### Сервер установился, но UI не работает +Проверить: +- разложился ли bundled UI +- корректен ли unit `hy2xs-admin` +- совпадает ли `HUI_INSTALL_DIR` с реальностью +- не сломан ли bind host / port + +### Hysteria скачалась, но не стартует +Проверить: +- валиден ли config +- совпадают ли listen port и firewall rule +- домен / SNI / TLS policy +- реальную установленную версию Hysteria + +### Тестовый клиент не подключается +Проверить: +- `server_name` +- порт +- `obfs.password` +- auth material +- что используется совместимый клиентский конфиг + +### Скорость не соответствует ожиданиям +Проверить: +- `bandwidth.*` на сервере +- клиентские `up_mbps/down_mbps` +- нет ли ложного ожидания, что один только host BBR решает speed policy + +### Изменили `post-install.env`, но runtime не изменился +Это ожидаемо. + +`post-install.env` — reference file, а не autoreconcile engine. + +## Правила эксплуатации + +1. Не править сервер как будто на нём есть builder. +2. Не считать bundled UI источником install-policy. +3. Не считать `post-install.env` автоматическим механизмом применения изменений. +4. Не расширять install-only baseline до lifecycle-manager без отдельного проектного решения. +5. Не смешивать install baseline и access/bot platform в одной документации. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7383e4f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,75 @@ +# HY2XS baseline docs + +Этот набор документов фиксирует актуальную baseline-модель HY2XS под следующие ограничения: + +- серверный транспорт: **ванильная Hysteria2** +- UI: **наш форк H UI / HY2XS admin**, поставляется **вместе с проектом** +- target OS: **только чистый Debian 12** +- оркестратор: **install-only**, только первичная установка и базовая настройка +- стек оркестратора: **Bun + TypeScript** +- target-side build: **запрещён** +- update / rollback / uninstall: **вне scope** +- сборка и упаковка: **отдельный локальный build layer** +- post-install state: **`/etc/hysteria/post-install.env`** +- клиентский delivery/access layer: **вне baseline этого пакета docs** + +## Главная архитектурная схема + +В этой редакции зафиксированы два слоя: + +1. **Builder layer** — работает **локально**, на отдельной машине. + Он собирает итоговый пакет, подготавливает **наш форк HY2XS admin**, компилирует **Bun/TypeScript оркестратор** в install-артефакт, упаковывает шаблоны, unit-файлы и примеры конфигов. + +2. **Runtime / target layer** — работает **на чистом Debian 12**. + Здесь нет сборщика. Здесь запускается только итоговый install package / orchestrator, который: + - ставит системные зависимости + - разворачивает **наш встроенный UI** + - забирает **свежую Hysteria2 из официального upstream** + - создаёт конфиги, systemd unit-файлы и `post-install.env` + - выполняет базовую настройку сервера + +## Базовые правила + +1. Hysteria2 не форкается и не вендорится в проект. +2. HY2XS admin форкается к себе и поставляется вместе с пакетом. +3. Оркестратор пишется на **Bun + TypeScript**. +4. На target нет `npm` / `pnpm` / `yarn` / `bun install` / transpile step. +5. На target нет логики update / rollback / uninstall. +6. Выдача доступа пользователям, Telegram-бот, billing, backend профилей и похожие контуры **не входят** в этот baseline. + +## Состав документов + +1. [01-architecture-baseline.md](01-architecture-baseline.md) +2. [02-build-layer-and-package.md](02-build-layer-and-package.md) +3. [03-server-hysteria2.md](03-server-hysteria2.md) +4. [04-admin-panel-h-ui-fork.md](04-admin-panel-h-ui-fork.md) +5. [05-client-and-access-scope.md](05-client-and-access-scope.md) +6. [06-speed-limits-and-congestion.md](06-speed-limits-and-congestion.md) +7. [07-systemd-and-firewall.md](07-systemd-and-firewall.md) +8. [08-orchestrator-spec.md](08-orchestrator-spec.md) +9. [09-post-install-env.md](09-post-install-env.md) +10. [10-access-layer-out-of-scope.md](10-access-layer-out-of-scope.md) +11. [11-testing-and-acceptance.md](11-testing-and-acceptance.md) +12. [12-operations-and-troubleshooting.md](12-operations-and-troubleshooting.md) +13. [examples/post-install.env.example](examples/post-install.env.example) + +## Жёсткие рамки baseline + +Не делаем: +- upgrade manager +- rollback manager +- uninstall +- reconcile engine +- target-side build pipeline +- Docker baseline +- multi-node +- port hopping +- Telegram-бот +- backend выдачи remote profiles +- «умную» миграцию сломанных старых инсталляций + +## Одной фразой + +Правильная baseline-модель теперь такая: + +**Локальный builder собирает install package с нашим форком HY2XS admin и Bun/TypeScript оркестратором; серверный install-only orchestrator ставит этот пакет на чистый Debian 12, тянет свежую Hysteria2 из upstream, разворачивает UI, создаёт systemd + nftables + post-install env и подготавливает рабочее серверное окружение.** diff --git a/docs/examples/post-install.env.example b/docs/examples/post-install.env.example new file mode 100644 index 0000000..b0168fd --- /dev/null +++ b/docs/examples/post-install.env.example @@ -0,0 +1,35 @@ +# post-install.env example +# Baseline reference file for a clean Debian 12 deployment. + +DEPLOY_TARGET_OS=debian12 +DEPLOY_TIMESTAMP=2026-04-13T10:00:00Z +PACKAGE_NAME=hy2xs-install-package +PACKAGE_BUILD_ID=build-20260413-001 +PACKAGE_VERSION=0.1.0 + +ORCH_SOURCE_STACK=bun-typescript +ORCH_BUILD_MODE=bun-compile +ORCH_BUILD_ID=orch-build-20260413-001 +ORCH_ENTRYPOINT=/usr/local/lib/hy2xs/hy2xs-orchestrator + +DEPLOY_DOMAIN=example.com +SSH_PORT=22 + +HY2_SOURCE=official-upstream +HY2_VERSION=v2.8.1 +HY2_LISTEN_HOST=0.0.0.0 +HY2_PORT=443 +HY2_OBFS_TYPE=salamander +HY2_OBFS_PASSWORD=CHANGE_ME +HY2_BANDWIDTH_UP_Mbps=50 +HY2_BANDWIDTH_DOWN_Mbps=50 +HY2_IGNORE_CLIENT_BANDWIDTH=false +HY2_CONFIG_PATH=/etc/hysteria/config.yaml + +HUI_ENABLED=true +HUI_FORK_REF=main +HUI_BUILD_ID=hy2xs-admin-build-20260413-001 +HUI_BIND_HOST=127.0.0.1 +HUI_PORT=8081 +HUI_INSTALL_DIR=/opt/hy2xs-admin +HUI_DATA_DIR=/var/lib/hy2xs-admin diff --git a/hy2xs_implementation_plan-no_git/00-README.txt b/hy2xs_implementation_plan-no_git/00-README.txt new file mode 100644 index 0000000..e6d450f --- /dev/null +++ b/hy2xs_implementation_plan-no_git/00-README.txt @@ -0,0 +1,41 @@ +HY2XS implementation plan + +Назначение +Этот пакет фиксирует полный план реализации системы HY2XS на основе ранее согласованных docs, но уже в прикладной форме: что именно делать, в каком порядке, как разложить проект и какие границы у каждого слоя. + +Зафиксированные решения +1. Название всей системы: HY2XS. +2. Название панели: HY2XS admin. +3. Серверный транспорт: ванильная Hysteria2 из official upstream во время установки. +4. UI: собственный fork H UI внутри проекта, поставляется вместе с пакетом. +5. Оркестратор: install-only, только под чистый Debian 12. +6. Стек оркестратора: Bun + TypeScript. +7. Builder layer: отдельно, локально, вне сервера. +8. Update / rollback / uninstall: вне scope. +9. UI переводим на русский или русский/английский, если двуязычность реализуется быстро и без раздувания scope. +10. Из UI вырезаем ссылки на оригинальный репозиторий, встроенный update-flow и любое поведение, завязанное на внешний upstream H UI. +11. Telegram-бот, backend выдачи доступа, remote profiles и похожий access layer — вне scope этого плана. + +Состав плана +01-scope-and-boundaries.txt +02-target-repo-structure.txt +03-builder-layer.txt +04-runtime-package-layout.txt +05-orchestrator-install-flow.txt +06-hysteria-runtime-layer.txt +07-hy2xs-admin-fork-plan.txt +08-localization-and-rebranding.txt +09-access-layer-out-of-scope.txt +10-post-install-env-and-config-policy.txt +11-testing-acceptance-and-smoke.txt +12-phased-roadmap.txt +13-task-breakdown-checklist.txt + +Что считать результатом +Результат этой работы — не абстрактные рассуждения, а один репозиторий/проект HY2XS, внутри которого: +- есть локальный builder; +- есть install-only orchestrator на Bun/TypeScript; +- есть встроенный fork HY2XS admin; +- есть шаблоны и unit-файлы; +- есть единый пакет для переноса на чистый Debian 12; +- сервер после установки готов к работе с Hysteria2, UI и базовым server environment. diff --git a/hy2xs_implementation_plan-no_git/01-scope-and-boundaries.txt b/hy2xs_implementation_plan-no_git/01-scope-and-boundaries.txt new file mode 100644 index 0000000..26933f8 --- /dev/null +++ b/hy2xs_implementation_plan-no_git/01-scope-and-boundaries.txt @@ -0,0 +1,46 @@ +HY2XS: scope and boundaries + +1. Что входит в реализацию +- Локальный builder layer. +- Итоговый install package. +- Install-only orchestrator для чистого Debian 12. +- Оркестратор на Bun + TypeScript. +- Vanilla Hysteria2 runtime, скачиваемая с official upstream во время установки. +- Встроенный fork панели HY2XS admin. +- Systemd unit-файлы. +- Базовый nftables baseline. +- post-install.env. +- Документация и acceptance checks. + +2. Что не входит в реализацию +- Telegram-бот. +- Backend/контур для remote profiles и выдачи ключей. +- Update manager. +- Rollback manager. +- Uninstall. +- Поддержка грязных или давно живущих серверов. +- Target-side build. +- Docker baseline. +- Multi-node и cluster-архитектура. +- Port hopping в первой версии. +- Автоматическая миграция старых H UI состояний. +- Полноценная собственная клиентская программа. + +3. Что считаем правильной эксплуатационной моделью +- Сборка выполняется локально. +- На сервер переносится только готовый пакет. +- Сервер выполняет только первичную установку и базовую настройку. +- Если сервер сломан или состояние стало непрозрачным, штатный путь — переустановка ОС и повторный install. + +4. Источники истины по слоям +- Runtime transport: Hysteria2 config + runtime state. +- UI layer: наш fork HY2XS admin. +- Install facts: /etc/hysteria/post-install.env. +- Install logic: исходники оркестратора на Bun/TypeScript + собранный install-артефакт. + +5. Главные архитектурные запреты +- Не скачивать HY2XS admin с внешнего upstream на target. +- Не компилировать UI на target. +- Не выполнять `bun install` / transpile / compile оркестратора на target. +- Не пытаться сделать оркестратор инструментом полного жизненного цикла. +- Не смешивать install baseline и access platform в одном scope. diff --git a/hy2xs_implementation_plan-no_git/02-target-repo-structure.txt b/hy2xs_implementation_plan-no_git/02-target-repo-structure.txt new file mode 100644 index 0000000..3981bf9 --- /dev/null +++ b/hy2xs_implementation_plan-no_git/02-target-repo-structure.txt @@ -0,0 +1,95 @@ +HY2XS: target repo structure + +Цель +Структура должна жёстко разделять builder, runtime package, fork UI, документацию и server-side install logic. + +Рекомендуемая структура + +project/ +├── README.md +├── docs/ +│ ├── architecture/ +│ ├── implementation/ +│ └── operations/ +├── builder/ +│ ├── build.sh +│ ├── lib/ +│ │ ├── common.sh +│ │ ├── package.sh +│ │ ├── ui.sh +│ │ └── verify.sh +│ ├── manifests/ +│ │ └── package.manifest +│ └── output/ +├── package/ +│ ├── install.sh +│ ├── orchestrator/ +│ │ └── hy2xs-orchestrator +│ ├── templates/ +│ │ ├── hysteria/ +│ │ ├── nftables/ +│ │ └── env/ +│ ├── systemd/ +│ │ ├── hysteria-server.service +│ │ └── hy2xs-admin.service +│ ├── ui/ +│ │ └── hy2xs-admin/ +│ ├── docs/ +│ └── metadata/ +│ ├── package.version +│ ├── package.build_id +│ └── checksums.txt +├── orchestrator/ +│ ├── package.json +│ ├── bun.lock +│ ├── tsconfig.json +│ ├── src/ +│ │ ├── cli.ts +│ │ ├── commands/ +│ │ │ └── install.ts +│ │ ├── steps/ +│ │ │ ├── preflight.ts +│ │ │ ├── deps.ts +│ │ │ ├── filesystem.ts +│ │ │ ├── hysteria.ts +│ │ │ ├── ui.ts +│ │ │ ├── systemd.ts +│ │ │ ├── firewall.ts +│ │ │ ├── env.ts +│ │ │ └── smoke.ts +│ │ ├── lib/ +│ │ └── types/ +│ └── dist/ +├── ui/ +│ └── hy2xs-admin-fork/ +│ ├── upstream-base/ +│ ├── app/ +│ ├── assets/ +│ ├── locales/ +│ │ ├── ru/ +│ │ └── en/ +│ ├── branding/ +│ ├── patches/ +│ └── BUILD_NOTES.md +├── examples/ +│ └── post-install.env.example +└── dist/ + └── hy2xs-install-.tar.gz + +Что важно +1. builder/ и orchestrator/ разделены. +2. package/ — staging area и состав будущего install package. +3. orchestrator/ — исходники install-only оркестратора на Bun/TypeScript. +4. package/orchestrator/ — уже собранный install-артефакт, а не исходники для target-side build. +5. ui/hy2xs-admin-fork/ — постоянный исходник нашего форка. +6. dist/ — только финальные артефакты. + +Минимальная допустимая упрощённая структура +Если хочешь не раздувать проект на старте, можно сократить до: +- builder/ +- orchestrator/ +- package/ +- ui/hy2xs-admin-fork/ +- docs/ +- dist/ +Но логическое разделение всё равно должно сохраниться. diff --git a/hy2xs_implementation_plan-no_git/03-builder-layer.txt b/hy2xs_implementation_plan-no_git/03-builder-layer.txt new file mode 100644 index 0000000..1b0ccf1 --- /dev/null +++ b/hy2xs_implementation_plan-no_git/03-builder-layer.txt @@ -0,0 +1,64 @@ +HY2XS: builder layer plan + +Цель builder layer +На Debian 12 amd64 build host собрать один чистый install package, который можно перенести на чистый Debian 12 без target-side build. + +Технологический выбор +Baseline packaging: shell-first. +- Основной packaging pipeline: sh/bash. +- Оркестратор при этом пишется на Bun + TypeScript. +- Builder на Debian 12 amd64 собирает install-артефакт оркестратора. +- На target не должно быть обязательного JS/TS toolchain шага. + +Задачи builder layer +1. Проверка структуры репозитория. +2. Проверка наличия обязательных файлов пакета. +3. Подготовка HY2XS admin fork к поставке. +4. Локальная сборка оркестратора из Bun/TypeScript. +5. Копирование UI-артефактов в package staging. +6. Копирование templates, systemd units, docs и examples. +7. Генерация metadata: version, build_id, checksums. +8. Упаковка итогового архива. + +Порядок реализации builder +Этап 1. +- Создать tools/build/build.sh. +- Создать tools/build/lib/common.sh. +- Создать tools/build/lib/verify.sh. +- Создать tools/build/lib/package.sh. +- Создать tools/build/lib/deps.sh. +- Создать tools/build/README.ru.md. + +Production-дополнение. +- Проверять Debian 12 amd64. +- Самостоятельно доставлять apt build-зависимости. +- Проверять и фиксировать версии Go/Bun/Node.js/pnpm. +- Использовать локальный управляемый toolchain при несовпадении версий. + +Этап 2. +- Реализовать очистку package staging directory. +- Реализовать копирование package/ skeleton. +- Реализовать сборку `orchestrator/` и копирование артефакта в `package/orchestrator/`. +- Реализовать копирование ui/hy2xs-admin-fork в package/ui/hy2xs-admin. + +Этап 3. +- Реализовать запись package.version и package.build_id. +- Реализовать checksums.txt. +- Реализовать создание dist/hy2xs-install-.tar.gz. + +Этап 4. +- Добавить builder smoke-проверку: архив собрался, в нём есть install.sh, оркестратор, UI, unit-файлы, templates. + +Что builder не делает +- Не качает Hysteria2. +- Не выполняет серверные действия. +- Не меняет production state. +- Не содержит uninstall/update логики. +- Не включает access/bot backend в baseline package. + +Acceptance criteria +- Одна команда локально создаёт переносимый install package. +- В package нет builder scripts. +- В package есть встроенный HY2XS admin. +- В package есть собранный Bun/TypeScript orchestrator. +- package можно передать на сервер без дополнительной сборки. diff --git a/hy2xs_implementation_plan-no_git/04-runtime-package-layout.txt b/hy2xs_implementation_plan-no_git/04-runtime-package-layout.txt new file mode 100644 index 0000000..27d3483 --- /dev/null +++ b/hy2xs_implementation_plan-no_git/04-runtime-package-layout.txt @@ -0,0 +1,50 @@ +HY2XS: runtime package layout + +Цель +Зафиксировать состав итогового install package, который должен попасть на target machine. + +Состав пакета +1. install entrypoint +- install.sh + +2. orchestrator artifact +- hy2xs-orchestrator + +3. templates +- Hysteria config template +- nftables template +- post-install.env template + +4. systemd units +- hysteria-server.service +- hy2xs-admin.service + +5. bundled UI +- HY2XS admin runtime files +- статические ассеты +- локали +- branding assets + +6. metadata +- package.version +- package.build_id +- checksums.txt + +7. docs/examples +- короткий README по установке +- post-install.env.example + +Что не должно быть в runtime package +- builder/ +- git history +- upstream remote URLs H UI +- update scripts панели +- ссылки на оригинальный бренд/репозиторий H UI в интерфейсе и документации пакета +- target-side dependency installation для оркестратора +- Telegram/access backend как обязательная часть baseline package + +Что должен делать install.sh +- быть единой точкой входа на target +- вызывать собранный артефакт оркестратора +- логировать запуск +- завершаться с понятным кодом ошибки diff --git a/hy2xs_implementation_plan-no_git/05-orchestrator-install-flow.txt b/hy2xs_implementation_plan-no_git/05-orchestrator-install-flow.txt new file mode 100644 index 0000000..830c1dc --- /dev/null +++ b/hy2xs_implementation_plan-no_git/05-orchestrator-install-flow.txt @@ -0,0 +1,87 @@ +HY2XS: orchestrator install flow + +Главная роль +Install-only orchestrator ставит систему на чистый Debian 12 и больше ничего не обещает. + +Технологическая фиксация +- Исходники оркестратора: Bun + TypeScript. +- Сборка оркестратора: локально, builder layer'ом. +- Исполнение на target: готовый install-артефакт через thin wrapper. + +Рекомендуемый порядок модулей +1. preflight +- проверить Debian 12 +- проверить root/sudo context +- проверить, что порт свободен +- проверить, что не существует конфликтующей старой установки + +2. deps +- установить системные зависимости +- проверить наличие systemd, nft, curl/wget, tar, openssl и прочего необходимого минимума + +3. filesystem +- создать системного пользователя hysteria +- создать каталоги: + /etc/hysteria + /var/lib/hysteria + /opt/hy2xs-admin + /var/lib/hy2xs-admin + /var/log/hy2xs + /usr/local/lib/hy2xs + +4. bundled UI deploy +- разложить HY2XS admin из пакета +- назначить владельца и права + +5. Hysteria install +- скачать свежую Hysteria2 из official upstream +- установить бинарь в согласованный путь +- зафиксировать фактическую версию + +6. config generation +- сгенерировать /etc/hysteria/config.yaml +- сгенерировать параметры obfs +- записать домен, порт, bandwidth policy + +7. systemd +- разложить hysteria-server.service +- разложить hy2xs-admin.service +- daemon-reload +- enable services + +8. firewall +- применить baseline nftables +- не терять SSH + +9. post-install.env +- создать /etc/hysteria/post-install.env +- записать package version, build id, orchestrator stack/build, Hysteria version, UI build info, порты и пути + +10. start and smoke +- стартовать сервисы +- проверить systemctl is-active +- проверить UDP listen +- проверить доступность UI + +Политика ошибок +- Любой конфликт неизвестного старого состояния = stop with error. +- Никакой сложной автомиграции. +- Ошибки должны быть текстовыми и пригодными для диагностики. + +CLI baseline +Допустимые флаги: +- --non-interactive +- --domain +- --port +- --ssh-port +- --skip-firewall +- --skip-start +- --ui-port +- --ui-bind-host + +Что не реализовывать +- update subcommands +- rollback subcommands +- uninstall subcommands +- reconcile logic +- bot/access-delivery subcommands diff --git a/hy2xs_implementation_plan-no_git/06-hysteria-runtime-layer.txt b/hy2xs_implementation_plan-no_git/06-hysteria-runtime-layer.txt new file mode 100644 index 0000000..703638d --- /dev/null +++ b/hy2xs_implementation_plan-no_git/06-hysteria-runtime-layer.txt @@ -0,0 +1,42 @@ +HY2XS: Hysteria runtime layer plan + +Цель +Сделать серверный транспорт предсказуемым и полностью ванильным со стороны Hysteria2. + +Решения +1. Hysteria2 не форкается. +2. Скачивается во время установки с official upstream. +3. Используется одна baseline-схема без port hopping. + +Базовый runtime policy +- Listen: 0.0.0.0: +- Transport: QUIC/UDP +- IPv4-only +- obfs.type: salamander +- obfs.password: генерируется при установке +- bandwidth.up: 50 Mbps +- bandwidth.down: 50 Mbps +- ignoreClientBandwidth: false + +Что надо реализовать +1. Шаблон server config. +2. Генератор переменных для шаблона. +3. Проверку валидности конфига перед запуском. +4. Фиксацию фактической версии Hysteria в post-install.env. + +Нужно зафиксировать в коде +- Единый путь к конфигу. +- Единый путь к бинарю. +- Единый путь к data dir. +- Единый набор сетевых переменных. + +Что не смешивать +- Не смешивать UI-состояние и transport-конфиг в одном месте. +- Не полагаться на host TCP BBR как на главный механизм управления Hysteria. +- Не смешивать install runtime layer и access/delivery layer. + +Acceptance criteria +- Сервис стартует через systemd. +- Конфиг читается без ошибки. +- Тестовый совместимый клиент подключается. +- Политика speed limit соответствует согласованной серверной и клиентской конфигурации. diff --git a/hy2xs_implementation_plan-no_git/07-hy2xs-admin-fork-plan.txt b/hy2xs_implementation_plan-no_git/07-hy2xs-admin-fork-plan.txt new file mode 100644 index 0000000..eeaee7b --- /dev/null +++ b/hy2xs_implementation_plan-no_git/07-hy2xs-admin-fork-plan.txt @@ -0,0 +1,60 @@ +HY2XS admin: fork implementation plan + +Цель +Превратить H UI в наш поддерживаемый внутренний компонент HY2XS admin. + +Главные задачи +1. Забрать исходный UI в собственный fork внутри проекта. +2. Отвязать интерфейс от оригинального бренда, ссылок и update-потока. +3. Подготовить UI к поставке внутри install package. +4. Сделать UI отдельным systemd-сервисом. + +Обязательные изменения в форке +A. Ребрендинг +- Новое имя продукта: HY2XS. +- Новое имя панели: HY2XS admin. +- Заменить названия в заголовках, логотипах, footer, title, README панели, системных сообщениях. + +B. Удаление upstream-зависимостей +- Удалить ссылки на оригинальный GitHub/Git repository. +- Удалить кнопки, меню и тексты, предлагающие update из внешнего upstream. +- Удалить или скрыть экран/логику самопроверки обновлений, если она встроена. +- Удалить любой текст вида "official repo", "check updates", "new version available" относительно оригинального H UI. + +C. Локализация +- Минимум: полный русский. +- Предпочтительно: русский + английский, если это делается быстро через словари/locale files без переписывания UI-логики. +- Китайский как основная locale больше не нужен для поставки HY2XS. + +D. Packaging readiness +- У UI должен быть предсказуемый build/runtime output. +- UI должен работать из локально поставленных файлов, без git clone и без target-side npm/yarn/pnpm build. + +Слои задач по форку +Этап 1. Первичная инвентаризация +- Найти все brand strings. +- Найти все repo/update ссылки. +- Найти все locales. +- Найти все места, где UI показывает своё имя. + +Этап 2. Ребрендинг +- Переименовать UI в HY2XS admin. +- Подменить logo/title/favicon, если есть. +- Обновить системные тексты. + +Этап 3. Вырезание update-flow +- Удалить пункты меню обновлений. +- Удалить backend/frontend обработчики update-функций. +- Удалить внешние endpoints и тексты об обновлениях. + +Этап 4. Локализация +- Вынести строки в locale-файлы, если это ещё не сделано. +- Создать ru locale. +- Опционально создать en locale. +- Проверить, что UI не содержит жёстко зашитых китайских строк. + +Этап 5. Runtime packaging +- Подготовить результат, который builder просто копирует в package/ui/hy2xs-admin. + +Техническое правило +Source-of-truth install lifecycle остаётся у оркестратора. HY2XS admin не должен расширять scope установки и не должен превращаться в update-manager. diff --git a/hy2xs_implementation_plan-no_git/08-localization-and-rebranding.txt b/hy2xs_implementation_plan-no_git/08-localization-and-rebranding.txt new file mode 100644 index 0000000..7c5112d --- /dev/null +++ b/hy2xs_implementation_plan-no_git/08-localization-and-rebranding.txt @@ -0,0 +1,44 @@ +HY2XS: localization and branding plan + +Цель +Сделать систему цельной по названию, языку интерфейса и операторскому UX. + +1. Названия +- Система: HY2XS +- Панель: HY2XS admin +- Service names: + - hysteria-server.service + - hy2xs-admin.service +- Пути и package naming должны использовать hy2xs как canonical slug + +2. Где переименовывать +- UI titles +- navbar/header/footer +- login page +- browser tab title +- package metadata +- docs +- install output +- post-install.env package name +- systemd description lines + +3. Политика языка +Минимум для первой версии: +- русский интерфейс панели +- русский install output/docs для оператора + +Предпочтительная быстрая модель: +- RU по умолчанию +- EN как дополнительная locale, если её можно добавить быстро +- никакой сложной i18n-платформы, если в исходном UI уже есть простой словарный механизм + +4. Что удалить +- китайские дефолтные тексты в видимых местах +- оригинальные названия продукта +- упоминания оригинальной панели как управляемого внешнего продукта + +5. Acceptance criteria +- В UI нет китайского языка в обычном операторском пути. +- В UI нет оригинального названия H UI. +- Во всех ключевых местах виден бренд HY2XS / HY2XS admin. +- Если включён EN, переключение не ломает layout. diff --git a/hy2xs_implementation_plan-no_git/09-access-layer-out-of-scope.txt b/hy2xs_implementation_plan-no_git/09-access-layer-out-of-scope.txt new file mode 100644 index 0000000..2ded114 --- /dev/null +++ b/hy2xs_implementation_plan-no_git/09-access-layer-out-of-scope.txt @@ -0,0 +1,29 @@ +HY2XS: access layer out of scope + +Цель +Явно убрать из плана всё, что не относится к install-only baseline. + +Что вне scope +- Telegram-бот. +- Backend выдачи ключей. +- Remote profile publishing. +- Deep links. +- Billing/подписки. +- Self-service кабинет. +- Любой обязательный пользовательский delivery layer. + +Что остаётся в scope +- Установка Hysteria2. +- Установка HY2XS admin. +- Настройка server config. +- Настройка systemd. +- Настройка firewall. +- Генерация post-install.env. + +Почему это важно +Если оставить access layer внутри baseline-плана, документация начинает неверно описывать продукт как платформу выдачи доступа. По факту здесь нужен только оркестратор установки и базовой серверной конфигурации. + +Минимальный deliverable +- Один install package. +- Один install-only orchestrator. +- Один reproducible install flow для чистого Debian 12. diff --git a/hy2xs_implementation_plan-no_git/10-post-install-env-and-config-policy.txt b/hy2xs_implementation_plan-no_git/10-post-install-env-and-config-policy.txt new file mode 100644 index 0000000..0f74238 --- /dev/null +++ b/hy2xs_implementation_plan-no_git/10-post-install-env-and-config-policy.txt @@ -0,0 +1,53 @@ +HY2XS: post-install env and config policy + +Цель +Оставить после установки один прозрачный reference file с фактами развёртывания. + +Путь +/etc/hysteria/post-install.env + +Что обязательно писать +Deploy/package: +- PACKAGE_NAME=HY2XS +- PACKAGE_VERSION +- PACKAGE_BUILD_ID +- DEPLOY_TIMESTAMP +- DEPLOY_TARGET_OS=debian-12 + +Orchestrator: +- ORCH_SOURCE_STACK=bun-typescript +- ORCH_BUILD_MODE +- ORCH_BUILD_ID +- ORCH_ENTRYPOINT + +Network/common: +- DEPLOY_DOMAIN +- SSH_PORT + +Hysteria: +- HY2_SOURCE=official-upstream +- HY2_VERSION +- HY2_LISTEN_HOST +- HY2_PORT +- HY2_OBFS_TYPE=salamander +- HY2_OBFS_PASSWORD +- HY2_BANDWIDTH_UP_Mbps=50 +- HY2_BANDWIDTH_DOWN_Mbps=50 +- HY2_IGNORE_CLIENT_BANDWIDTH=false +- HY2_CONFIG_PATH + +HY2XS admin: +- HUI_ENABLED=true +- HUI_FORK_REF +- HUI_BUILD_ID +- HUI_BIND_HOST +- HUI_PORT +- HUI_INSTALL_DIR +- HUI_DATA_DIR +- HUI_BRAND=HY2XS admin +- HUI_DEFAULT_LOCALE=ru + +Правило использования +- post-install.env — reference file. +- Изменение этого файла само по себе не должно считаться применением runtime-изменений. +- Любые ручные правки оператора должны потом осознанно переноситься в реальные рабочие конфиги и применяться документированным способом. diff --git a/hy2xs_implementation_plan-no_git/11-testing-acceptance-and-smoke.txt b/hy2xs_implementation_plan-no_git/11-testing-acceptance-and-smoke.txt new file mode 100644 index 0000000..1b7dafa --- /dev/null +++ b/hy2xs_implementation_plan-no_git/11-testing-acceptance-and-smoke.txt @@ -0,0 +1,48 @@ +HY2XS: testing, acceptance and smoke plan + +1. Builder checks +- package успешно собирается локально +- в архиве есть UI, install entrypoint, orchestrator artifact, templates, units, metadata +- нет builder мусора в финальном package + +2. Target install checks +- чистый Debian 12 +- install flow проходит без ручной сборки +- Hysteria скачана с upstream +- HY2XS admin разложен из bundled package +- созданы оба systemd unit +- создан post-install.env +- post-install.env фиксирует Bun/TypeScript orchestrator stack + +3. Runtime checks +- systemctl is-active hysteria-server = active +- systemctl is-active hy2xs-admin = active +- UDP порт слушается +- SSH не потерян после firewall +- UI доступен на заданном bind host/port +- тестовый совместимый клиент подключается + +4. Branding/localization checks +- в UI нет китайских строк на основных маршрутах +- в UI нет ссылок на оригинальный репозиторий +- в UI нет update-кнопок и update-текстов +- бренд везде HY2XS / HY2XS admin + +5. Negative checks +- порт уже занят +- старое состояние найдено +- неверный домен +- ошибка скачивания Hysteria2 +- конфликтующие файлы UI +- отсутствует root context + +6. Acceptance definition +Система считается реализованной, когда: +- локальный builder собирает install package; +- target install-only flow разворачивает систему на чистом Debian 12; +- HY2XS admin работает как встроенный форк; +- Hysteria2 получена с official upstream; +- оркестратор зафиксирован как Bun/TypeScript stack; +- UI русифицирован и ребрендирован; +- upstream update/repo logic удалена из UI; +- install acceptance не зависит от bot/access layer. diff --git a/hy2xs_implementation_plan-no_git/12-phased-roadmap.txt b/hy2xs_implementation_plan-no_git/12-phased-roadmap.txt new file mode 100644 index 0000000..a303be2 --- /dev/null +++ b/hy2xs_implementation_plan-no_git/12-phased-roadmap.txt @@ -0,0 +1,59 @@ +HY2XS: phased roadmap + +Фаза 1. Skeleton and structure +Результат: +- создана структура репозитория; +- созданы каталоги builder/, orchestrator/, package/, ui/hy2xs-admin-fork/, docs/, dist/. + +Фаза 2. Builder baseline +Результат: +- build.sh собирает package staging; +- builder локально собирает Bun/TypeScript orchestrator artifact; +- генерируется итоговый архив; +- builder проверяет минимальную целостность пакета. + +Фаза 3. Install-only orchestrator baseline +Результат: +- чистый Debian 12 проходит preflight; +- раскладываются каталоги; +- создаётся post-install.env; +- скачивается Hysteria2; +- создаются unit-файлы. + +Фаза 4. Hysteria runtime baseline +Результат: +- Hysteria2 стартует через systemd; +- слушает нужный UDP-порт; +- firewall baseline применён; +- smoke checks проходят. + +Фаза 5. HY2XS admin fork baseline +Результат: +- UI форк находится в проекте; +- поставляется в пакете; +- стартует отдельным сервисом; +- не содержит target-side build. + +Фаза 6. Rebranding and de-upstreaming +Результат: +- UI переименован в HY2XS admin; +- удалены ссылки на оригинальный repo; +- удалены update-кнопки и update-flow; +- бренд HY2XS отражён в docs, install output и metadata. + +Фаза 7. Localization +Результат: +- русский интерфейс готов полностью; +- опционально добавлен английский; +- китайские строки не видны оператору. + +Фаза 8. Scope cleanup and docs alignment +Результат: +- Telegram/access layer убран из baseline docs; +- в документации зафиксирован стек оркестратора Bun + TypeScript; +- install scope отделён от user delivery scope. + +Фаза 9. Final acceptance +Результат: +- весь install flow от builder до рабочего сервера проходит воспроизводимо; +- docs соответствуют фактической реализации. diff --git a/hy2xs_implementation_plan-no_git/13-task-breakdown-checklist.txt b/hy2xs_implementation_plan-no_git/13-task-breakdown-checklist.txt new file mode 100644 index 0000000..dde76ad --- /dev/null +++ b/hy2xs_implementation_plan-no_git/13-task-breakdown-checklist.txt @@ -0,0 +1,56 @@ +HY2XS: task breakdown checklist + +A. Repo and structure +[ ] Создать целевую структуру проекта. +[ ] Разнести builder, package, orchestrator, ui и docs. +[ ] Добавить dist/ и metadata policy. + +B. Builder +[ ] Реализовать build.sh. +[ ] Реализовать staging сборку пакета. +[ ] Реализовать локальную сборку Bun/TypeScript orchestrator artifact. +[ ] Реализовать checksums и build_id. +[ ] Проверить содержимое архива. + +C. Orchestrator +[ ] Реализовать preflight. +[ ] Реализовать deps. +[ ] Реализовать filesystem. +[ ] Реализовать Hysteria install from upstream. +[ ] Реализовать UI deploy from bundled package. +[ ] Реализовать config generation. +[ ] Реализовать systemd units deployment. +[ ] Реализовать nftables baseline. +[ ] Реализовать post-install.env generation. +[ ] Реализовать smoke checks. + +D. Hysteria baseline +[ ] Сделать template config. +[ ] Сделать obfs password generation. +[ ] Зафиксировать bandwidth defaults. +[ ] Проверить launch через systemd. + +E. HY2XS admin fork +[ ] Инвентаризировать brand strings. +[ ] Инвентаризировать repo/update references. +[ ] Переименовать UI в HY2XS admin. +[ ] Удалить update UI/actions. +[ ] Удалить ссылки на оригинальный repo. +[ ] Подготовить packaged runtime output. + +F. Localization +[ ] Вынести строки в локали, если нужно. +[ ] Добавить ru locale. +[ ] Опционально добавить en locale. +[ ] Проверить отсутствие китайского в основных маршрутах. + +G. Scope alignment +[ ] Убрать Telegram/access layer из baseline docs. +[ ] Убрать bot/profile assumptions из acceptance. +[ ] Зафиксировать Bun + TypeScript как стек оркестратора. +[ ] Зафиксировать deliverable как install-only server environment. + +H. Docs and acceptance +[ ] Обновить docs по факту реализации. +[ ] Прогнать acceptance checklist. +[ ] Зафиксировать финальные инварианты. diff --git a/hy2xs_implementation_plan-no_git/about_build.txt b/hy2xs_implementation_plan-no_git/about_build.txt new file mode 100644 index 0000000..4f14551 --- /dev/null +++ b/hy2xs_implementation_plan-no_git/about_build.txt @@ -0,0 +1,8 @@ + +### 💎 + +"Проблема" с типами в Bun вообще не ваша забота на этапе создания бинарника. При сборке они просто вырезаются. Ваш план: +1. Пишете логику оркестратора на TS, как мы обсуждали. +2. Перед финальной сборкой прогоняете `bunx tsc --noEmit` для проверки типов, если хотите перестраховаться. +3. Собираете командой `bun build ./src/main.ts --compile --outfile orchestrator`. +4. Запускаете на любом сервере: `./orchestrator`. diff --git a/orchestrator/bun.lock b/orchestrator/bun.lock new file mode 100644 index 0000000..b36f77e --- /dev/null +++ b/orchestrator/bun.lock @@ -0,0 +1,22 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "hy2xs-orchestrator", + "devDependencies": { + "bun-types": "^1.1.0", + "typescript": "^5.4.5", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + } +} diff --git a/orchestrator/package.json b/orchestrator/package.json new file mode 100644 index 0000000..532ffd0 --- /dev/null +++ b/orchestrator/package.json @@ -0,0 +1,14 @@ +{ + "name": "hy2xs-orchestrator", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "check": "tsc --noEmit", + "build": "bun build src/cli.ts --compile --target=bun-linux-x64 --outfile dist/hy2xs-orchestrator" + }, + "devDependencies": { + "bun-types": "^1.1.0", + "typescript": "^5.4.5" + } +} diff --git a/orchestrator/src/cli.ts b/orchestrator/src/cli.ts new file mode 100644 index 0000000..854d3ab --- /dev/null +++ b/orchestrator/src/cli.ts @@ -0,0 +1,101 @@ +import { install } from "./commands/install"; +import type { InstallOptions } from "./types/context"; + +function usage(): never { + console.error("Usage: hy2xs-orchestrator install --package-dir [--domain ] [--port ] [--ssh-port ] [--skip-firewall] [--skip-start] [--ui-port ] [--ui-bind-host ] [--non-interactive]"); + process.exit(2); +} + +function takeValue(args: string[], index: number, flag: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + console.error(`Missing value for ${flag}`); + usage(); + } + return value; +} + +function parseInstallOptions(args: string[]): InstallOptions { + const options: InstallOptions = { + packageDir: "", + nonInteractive: false, + domain: "", + port: 443, + sshPort: 22, + skipFirewall: false, + skipStart: false, + uiPort: 8080, + uiBindHost: "127.0.0.1" + }; + + 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 "--non-interactive": + options.nonInteractive = true; + 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": + options.skipFirewall = true; + break; + case "--skip-start": + options.skipStart = true; + break; + case "--ui-port": + options.uiPort = Number(takeValue(args, i, arg)); + i += 1; + break; + case "--ui-bind-host": + options.uiBindHost = takeValue(args, i, arg); + i += 1; + break; + default: + console.error(`Unknown argument: ${arg}`); + usage(); + } + } + + if (!options.packageDir) { + console.error("Missing --package-dir"); + usage(); + } + + for (const [name, value] of Object.entries({ port: options.port, sshPort: options.sshPort, uiPort: options.uiPort })) { + if (!Number.isInteger(value) || value < 1 || value > 65535) { + console.error(`Invalid ${name}: ${value}`); + usage(); + } + } + + return options; +} + +async function main(): Promise { + const [command, ...args] = Bun.argv.slice(2); + if (command !== "install") { + usage(); + } + + await install(parseInstallOptions(args)); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[hy2xs] ERROR: ${message}`); + process.exit(1); +}); diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts new file mode 100644 index 0000000..554079f --- /dev/null +++ b/orchestrator/src/commands/install.ts @@ -0,0 +1,61 @@ +import { randomBytes } from "node:crypto"; +import type { InstallContext, InstallOptions } from "../types/context"; +import { readText } from "../lib/fs"; +import { step } from "../lib/log"; +import { preflight } from "../steps/preflight"; +import { installDeps } from "../steps/deps"; +import { prepareFilesystem } from "../steps/filesystem"; +import { deployUi } from "../steps/ui"; +import { installHysteria } from "../steps/hysteria"; +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"; + +async function readPackageValue(packageDir: string, file: string, fallback: string): Promise { + try { + return (await readText(`${packageDir}/metadata/${file}`)).trim(); + } catch { + return fallback; + } +} + +function secret(): string { + return randomBytes(24).toString("base64url"); +} + +export async function install(options: InstallOptions): Promise { + const context: InstallContext = { + options, + packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"), + packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), + installDate: new Date().toISOString(), + hysteriaAuthPassword: secret(), + hysteriaObfsPassword: secret(), + hysteriaApiSecret: secret(), + hysteriaApiPort: 36712, + hysteriaVersion: "unknown" + }; + + step("preflight"); + await preflight(context); + step("system dependencies"); + await installDeps(context); + step("filesystem"); + await prepareFilesystem(context); + step("bundled UI"); + await deployUi(context); + step("Hysteria2 upstream install"); + await installHysteria(context); + step("config generation"); + await generateConfig(context); + step("systemd units"); + await deploySystemd(context); + step("firewall"); + await applyFirewall(context); + step("post-install env"); + await writePostInstallEnv(context); + step("smoke checks"); + await smoke(context); +} diff --git a/orchestrator/src/lib/fs.ts b/orchestrator/src/lib/fs.ts new file mode 100644 index 0000000..c41944d --- /dev/null +++ b/orchestrator/src/lib/fs.ts @@ -0,0 +1,28 @@ +export async function exists(path: string): Promise { + return await Bun.file(path).exists(); +} + +export async function readText(path: string): Promise { + return await Bun.file(path).text(); +} + +export async function writeText(path: string, data: string, mode?: number): Promise { + await Bun.write(path, data); + if (mode !== undefined) { + const result = Bun.spawnSync(["chmod", mode.toString(8), path], { + stdout: "pipe", + stderr: "pipe" + }); + if (!result.success) { + throw new Error(`chmod failed for ${path}: ${result.stderr.toString()}`); + } + } +} + +export function renderTemplate(template: string, values: Record): string { + let rendered = template; + for (const [key, value] of Object.entries(values)) { + rendered = rendered.replaceAll(`{{${key}}}`, String(value)); + } + return rendered; +} diff --git a/orchestrator/src/lib/log.ts b/orchestrator/src/lib/log.ts new file mode 100644 index 0000000..c0a3e2e --- /dev/null +++ b/orchestrator/src/lib/log.ts @@ -0,0 +1,11 @@ +export function step(name: string): void { + console.log(`\n[hy2xs] ==> ${name}`); +} + +export function info(message: string): void { + console.log(`[hy2xs] ${message}`); +} + +export function fail(message: string): never { + throw new Error(message); +} diff --git a/orchestrator/src/lib/process.ts b/orchestrator/src/lib/process.ts new file mode 100644 index 0000000..ca35552 --- /dev/null +++ b/orchestrator/src/lib/process.ts @@ -0,0 +1,50 @@ +import { info } from "./log"; + +function shellQuote(value: unknown): string { + const text = String(value); + if (text.length === 0) { + return "''"; + } + return `'${text.replaceAll("'", "'\\''")}'`; +} + +function renderCommand(strings: TemplateStringsArray, values: unknown[]): string { + let command = ""; + for (let i = 0; i < strings.length; i += 1) { + command += strings[i]; + if (i < values.length) { + command += shellQuote(values[i]); + } + } + return command; +} + +export async function run(command: TemplateStringsArray, ...args: unknown[]): Promise { + const rendered = renderCommand(command, args); + const process = Bun.spawn(["sh", "-c", rendered], { + stdout: "pipe", + stderr: "pipe" + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited + ]); + if (exitCode !== 0) { + throw new Error(`command failed (${exitCode}): ${rendered}\n${stderr.trim()}`); + } + return stdout.trim(); +} + +export async function runVisible(command: TemplateStringsArray, ...args: unknown[]): Promise { + const rendered = renderCommand(command, args); + info(`running: ${rendered}`); + const process = Bun.spawn(["sh", "-c", rendered], { + stdout: "inherit", + stderr: "inherit" + }); + const exitCode = await process.exited; + if (exitCode !== 0) { + throw new Error(`command failed (${exitCode}): ${rendered}`); + } +} diff --git a/orchestrator/src/steps/config.ts b/orchestrator/src/steps/config.ts new file mode 100644 index 0000000..b05b7c3 --- /dev/null +++ b/orchestrator/src/steps/config.ts @@ -0,0 +1,21 @@ +import type { InstallContext } from "../types/context"; +import { readText, renderTemplate, writeText } from "../lib/fs"; +import { runVisible } from "../lib/process"; + +export async function generateConfig(context: InstallContext): Promise { + const template = await readText(`${context.options.packageDir}/templates/hysteria/config.yaml.tpl`); + const rendered = renderTemplate(template, { + HYSTERIA_PORT: context.options.port, + HYSTERIA_AUTH_PASSWORD: context.hysteriaAuthPassword, + HYSTERIA_OBFS_PASSWORD: context.hysteriaObfsPassword, + HYSTERIA_API_PORT: context.hysteriaApiPort, + HYSTERIA_API_SECRET: context.hysteriaApiSecret, + UI_PORT: context.options.uiPort, + BANDWIDTH_UP: "50 mbps", + BANDWIDTH_DOWN: "50 mbps" + }); + + await writeText("/etc/hysteria/config.yaml", 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`chown hysteria:hysteria /etc/hysteria/config.yaml /etc/hysteria/server.key /etc/hysteria/server.crt`; +} diff --git a/orchestrator/src/steps/deps.ts b/orchestrator/src/steps/deps.ts new file mode 100644 index 0000000..4ee1b5a --- /dev/null +++ b/orchestrator/src/steps/deps.ts @@ -0,0 +1,7 @@ +import type { InstallContext } from "../types/context"; +import { runVisible } from "../lib/process"; + +export async function installDeps(_context: InstallContext): Promise { + await runVisible`apt-get update`; + await runVisible`apt-get install -y ca-certificates curl iproute2 tar openssl nftables systemd`; +} diff --git a/orchestrator/src/steps/env.ts b/orchestrator/src/steps/env.ts new file mode 100644 index 0000000..8512e0f --- /dev/null +++ b/orchestrator/src/steps/env.ts @@ -0,0 +1,20 @@ +import type { InstallContext } from "../types/context"; +import { readText, renderTemplate, writeText } from "../lib/fs"; + +export async function writePostInstallEnv(context: InstallContext): Promise { + const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/env/post-install.env.tpl`), { + PACKAGE_VERSION: context.packageVersion, + PACKAGE_BUILD_ID: context.packageBuildId, + INSTALL_DATE: context.installDate, + DOMAIN: context.options.domain, + SSH_PORT: context.options.sshPort, + HYSTERIA_VERSION: context.hysteriaVersion, + HYSTERIA_PORT: context.options.port, + HYSTERIA_OBFS_PASSWORD: context.hysteriaObfsPassword, + HYSTERIA_API_PORT: context.hysteriaApiPort, + UI_BIND_HOST: context.options.uiBindHost, + UI_PORT: context.options.uiPort + }); + + await writeText("/etc/hysteria/post-install.env", rendered, 0o600); +} diff --git a/orchestrator/src/steps/filesystem.ts b/orchestrator/src/steps/filesystem.ts new file mode 100644 index 0000000..7fb473d --- /dev/null +++ b/orchestrator/src/steps/filesystem.ts @@ -0,0 +1,9 @@ +import type { InstallContext } from "../types/context"; +import { runVisible } from "../lib/process"; + +export async function prepareFilesystem(_context: InstallContext): Promise { + 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`chown -R hysteria:hysteria /etc/hysteria /var/lib/hysteria`; + await runVisible`chown -R root:root /var/lib/hy2xs-admin`; +} diff --git a/orchestrator/src/steps/firewall.ts b/orchestrator/src/steps/firewall.ts new file mode 100644 index 0000000..d3acaf7 --- /dev/null +++ b/orchestrator/src/steps/firewall.ts @@ -0,0 +1,22 @@ +import type { InstallContext } from "../types/context"; +import { readText, renderTemplate, writeText } from "../lib/fs"; +import { info } from "../lib/log"; +import { runVisible } from "../lib/process"; + +export async function applyFirewall(context: InstallContext): Promise { + if (context.options.skipFirewall) { + info("firewall skipped by flag"); + return; + } + + const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/nftables/hy2xs.nft.tpl`), { + SSH_PORT: context.options.sshPort, + HYSTERIA_PORT: context.options.port, + UI_PORT: context.options.uiPort + }); + + await runVisible`cp -a /etc/nftables.conf /etc/nftables.conf.hy2xs.bak 2>/dev/null || true`; + await writeText("/etc/nftables.conf", rendered, 0o644); + await runVisible`nft -f /etc/nftables.conf`; + await runVisible`systemctl enable --now nftables`; +} diff --git a/orchestrator/src/steps/hysteria.ts b/orchestrator/src/steps/hysteria.ts new file mode 100644 index 0000000..b52b00c --- /dev/null +++ b/orchestrator/src/steps/hysteria.ts @@ -0,0 +1,8 @@ +import type { InstallContext } from "../types/context"; +import { run, runVisible } from "../lib/process"; + +export async function installHysteria(context: InstallContext): Promise { + await runVisible`curl -fsSL https://get.hy2.sh/ -o /tmp/hy2xs-install-hysteria.sh`; + await runVisible`sh /tmp/hy2xs-install-hysteria.sh`; + context.hysteriaVersion = await run`/usr/local/bin/hysteria version`; +} diff --git a/orchestrator/src/steps/preflight.ts b/orchestrator/src/steps/preflight.ts new file mode 100644 index 0000000..50696cb --- /dev/null +++ b/orchestrator/src/steps/preflight.ts @@ -0,0 +1,53 @@ +import type { InstallContext } from "../types/context"; +import { exists, readText } from "../lib/fs"; +import { fail } from "../lib/log"; +import { run } from "../lib/process"; + +async function isPortBusy(port: number): Promise { + try { + const output = await run`ss -H -lntu`; + return output.split("\n").some((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`)); + } catch { + return false; + } +} + +export async function preflight(context: InstallContext): Promise { + if (process.getuid?.() !== 0) { + fail("installer must run as root"); + } + + const osRelease = await readText("/etc/os-release"); + if (!/^ID=debian$/m.test(osRelease) || !/^VERSION_ID="?12"?$/m.test(osRelease)) { + fail("HY2XS baseline supports only clean Debian 12"); + } + + if (!(await exists(`${context.options.packageDir}/ui/hy2xs-admin`))) { + fail("bundled HY2XS admin is missing from install package"); + } + + if (await exists("/etc/hysteria/post-install.env")) { + fail("existing HY2XS post-install.env found; update/repair is out of scope"); + } + + if (await exists("/opt/hy2xs-admin")) { + fail("existing /opt/hy2xs-admin found; conflicting old state"); + } + + const ports = new Set([context.options.port, context.options.uiPort]); + if (ports.size !== 2) { + fail("Hysteria port and UI port must be different"); + } + + if (context.options.domain && !/^[a-zA-Z0-9.-]+$/.test(context.options.domain)) { + fail("domain contains unsupported characters"); + } + + if (await isPortBusy(context.options.port)) { + fail(`Hysteria UDP/TCP port already appears to be in use: ${context.options.port}`); + } + + if (await isPortBusy(context.options.uiPort)) { + fail(`HY2XS admin port already appears to be in use: ${context.options.uiPort}`); + } +} diff --git a/orchestrator/src/steps/smoke.ts b/orchestrator/src/steps/smoke.ts new file mode 100644 index 0000000..1c54fda --- /dev/null +++ b/orchestrator/src/steps/smoke.ts @@ -0,0 +1,19 @@ +import type { InstallContext } from "../types/context"; +import { info } from "../lib/log"; +import { runVisible } from "../lib/process"; + +export async function smoke(context: InstallContext): Promise { + if (context.options.skipStart) { + info("service start and smoke checks skipped by flag"); + return; + } + + await runVisible`systemctl start hysteria-server hy2xs-admin`; + await runVisible`systemctl is-active --quiet hysteria-server`; + await runVisible`systemctl is-active --quiet hy2xs-admin`; + await runVisible`/usr/local/bin/hysteria version`; + await runVisible`test -s /etc/hysteria/config.yaml`; + await runVisible`test -s /etc/hysteria/post-install.env`; + await runVisible`ss -H -lntu | grep -q ':${context.options.uiPort} '`; + await runVisible`curl -fsS --max-time 5 http://127.0.0.1:${context.options.uiPort}/ >/dev/null`; +} diff --git a/orchestrator/src/steps/systemd.ts b/orchestrator/src/steps/systemd.ts new file mode 100644 index 0000000..6487729 --- /dev/null +++ b/orchestrator/src/steps/systemd.ts @@ -0,0 +1,18 @@ +import type { InstallContext } from "../types/context"; +import { readText, renderTemplate, writeText } from "../lib/fs"; +import { runVisible } from "../lib/process"; + +export async function deploySystemd(context: InstallContext): Promise { + const values = { + UI_BIND_HOST: context.options.uiBindHost, + UI_PORT: context.options.uiPort + }; + + const hysteriaUnit = await readText(`${context.options.packageDir}/systemd/hysteria-server.service`); + const adminUnit = renderTemplate(await readText(`${context.options.packageDir}/systemd/hy2xs-admin.service`), values); + + await writeText("/etc/systemd/system/hysteria-server.service", hysteriaUnit, 0o644); + await writeText("/etc/systemd/system/hy2xs-admin.service", adminUnit, 0o644); + await runVisible`systemctl daemon-reload`; + await runVisible`systemctl enable hysteria-server hy2xs-admin`; +} diff --git a/orchestrator/src/steps/ui.ts b/orchestrator/src/steps/ui.ts new file mode 100644 index 0000000..a741b36 --- /dev/null +++ b/orchestrator/src/steps/ui.ts @@ -0,0 +1,8 @@ +import type { InstallContext } from "../types/context"; +import { runVisible } from "../lib/process"; + +export async function deployUi(context: InstallContext): Promise { + await runVisible`cp -a ${context.options.packageDir}/ui/hy2xs-admin/. /opt/hy2xs-admin/`; + await runVisible`chown -R root:root /opt/hy2xs-admin`; + await runVisible`chmod -R go-w /opt/hy2xs-admin`; +} diff --git a/orchestrator/src/types/context.ts b/orchestrator/src/types/context.ts new file mode 100644 index 0000000..c044540 --- /dev/null +++ b/orchestrator/src/types/context.ts @@ -0,0 +1,23 @@ +export type InstallOptions = { + packageDir: string; + nonInteractive: boolean; + domain: string; + port: number; + sshPort: number; + skipFirewall: boolean; + skipStart: boolean; + uiPort: number; + uiBindHost: string; +}; + +export type InstallContext = { + options: InstallOptions; + packageVersion: string; + packageBuildId: string; + installDate: string; + hysteriaAuthPassword: string; + hysteriaObfsPassword: string; + hysteriaApiSecret: string; + hysteriaApiPort: number; + hysteriaVersion: string; +}; diff --git a/orchestrator/tsconfig.json b/orchestrator/tsconfig.json new file mode 100644 index 0000000..9378fe6 --- /dev/null +++ b/orchestrator/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "types": ["bun-types"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/package/docs/README.md b/package/docs/README.md new file mode 100644 index 0000000..1125691 --- /dev/null +++ b/package/docs/README.md @@ -0,0 +1,11 @@ +# HY2XS install package + +This package is generated by the local builder layer. It is intended for a clean Debian 12 target and contains a compiled install-only orchestrator, bundled HY2XS admin files, templates, systemd units, and package metadata. + +Run as root: + +```sh +./install.sh --non-interactive --domain example.com +``` + +No target-side JavaScript or TypeScript build step is part of the baseline. diff --git a/package/examples/post-install.env.example b/package/examples/post-install.env.example new file mode 100644 index 0000000..e7e2f8a --- /dev/null +++ b/package/examples/post-install.env.example @@ -0,0 +1,14 @@ +# post-install.env example +# Baseline reference file for a clean Debian 12 deployment. + +HY2XS_PACKAGE_VERSION=0.1.0 +HY2XS_PACKAGE_BUILD_ID=build-20260413-001 +HY2XS_ORCHESTRATOR_STACK=Bun+TypeScript +HY2XS_INSTALL_DATE=2026-04-13T10:00:00Z +HY2XS_DOMAIN=example.com +HY2XS_HYSTERIA_VERSION=v2.8.1 +HY2XS_HYSTERIA_PORT=443 +HY2XS_UI_BIND_HOST=127.0.0.1 +HY2XS_UI_PORT=8081 +HY2XS_ADMIN_PATH=/opt/hy2xs-admin +HY2XS_CONFIG_PATH=/etc/hysteria/config.yaml diff --git a/package/install.sh b/package/install.sh new file mode 100644 index 0000000..35a475f --- /dev/null +++ b/package/install.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +set -eu + +PACKAGE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +ORCHESTRATOR="$PACKAGE_DIR/orchestrator/hy2xs-orchestrator" + +log() { + printf '[hy2xs-install] %s\n' "$*" +} + +fail() { + printf '[hy2xs-install] ERROR: %s\n' "$*" >&2 + exit 1 +} + +if [ "$(id -u)" != "0" ]; then + fail "HY2XS install must run as root." +fi + +if [ ! -x "$ORCHESTRATOR" ]; then + fail "Missing executable orchestrator artifact: $ORCHESTRATOR" +fi + +log "package directory: $PACKAGE_DIR" +log "starting install-only orchestrator" +exec "$ORCHESTRATOR" install --package-dir "$PACKAGE_DIR" "$@" diff --git a/package/systemd/hy2xs-admin.service b/package/systemd/hy2xs-admin.service new file mode 100644 index 0000000..d04fae2 --- /dev/null +++ b/package/systemd/hy2xs-admin.service @@ -0,0 +1,17 @@ +[Unit] +Description=HY2XS admin +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/hy2xs-admin +Environment=HUI_DATA=/var/lib/hy2xs-admin/ +Environment=HY2XS_UI_BIND_HOST={{UI_BIND_HOST}} +ExecStart=/opt/hy2xs-admin/hy2xs-admin -p {{UI_PORT}} +Restart=on-failure +RestartSec=5s +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/package/systemd/hysteria-server.service b/package/systemd/hysteria-server.service new file mode 100644 index 0000000..bbcac94 --- /dev/null +++ b/package/systemd/hysteria-server.service @@ -0,0 +1,17 @@ +[Unit] +Description=HY2XS Hysteria2 server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=hysteria +Group=hysteria +ExecStart=/usr/local/bin/hysteria server -c /etc/hysteria/config.yaml +Restart=on-failure +RestartSec=5s +AmbientCapabilities=CAP_NET_BIND_SERVICE +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/package/templates/env/post-install.env.tpl b/package/templates/env/post-install.env.tpl new file mode 100644 index 0000000..694b801 --- /dev/null +++ b/package/templates/env/post-install.env.tpl @@ -0,0 +1,38 @@ +# HY2XS post-install reference file. +# Файл создаётся оркестратором после первичной установки и не является runtime-конфигом. + +DEPLOY_TARGET_OS=debian12 +DEPLOY_TIMESTAMP={{INSTALL_DATE}} +PACKAGE_NAME=hy2xs-install-package +PACKAGE_BUILD_ID={{PACKAGE_BUILD_ID}} +PACKAGE_VERSION={{PACKAGE_VERSION}} + +ORCH_SOURCE_STACK=bun-typescript +ORCH_BUILD_MODE=bun-compile +ORCH_BUILD_ID={{PACKAGE_BUILD_ID}} +ORCH_ENTRYPOINT=/usr/local/lib/hy2xs/hy2xs-orchestrator + +DEPLOY_DOMAIN={{DOMAIN}} +SSH_PORT={{SSH_PORT}} + +HY2_SOURCE=official-upstream +HY2_VERSION={{HYSTERIA_VERSION}} +HY2_LISTEN_HOST=0.0.0.0 +HY2_PORT={{HYSTERIA_PORT}} +HY2_AUTH_MODE=http +HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth +HY2_TRAFFIC_STATS_LISTEN=127.0.0.1:{{HYSTERIA_API_PORT}} +HY2_OBFS_TYPE=salamander +HY2_OBFS_PASSWORD={{HYSTERIA_OBFS_PASSWORD}} +HY2_BANDWIDTH_UP_Mbps=50 +HY2_BANDWIDTH_DOWN_Mbps=50 +HY2_IGNORE_CLIENT_BANDWIDTH=false +HY2_CONFIG_PATH=/etc/hysteria/config.yaml + +HUI_ENABLED=true +HUI_FORK_REF=packaged +HUI_BUILD_ID={{PACKAGE_BUILD_ID}} +HUI_BIND_HOST={{UI_BIND_HOST}} +HUI_PORT={{UI_PORT}} +HUI_INSTALL_DIR=/opt/hy2xs-admin +HUI_DATA_DIR=/var/lib/hy2xs-admin diff --git a/package/templates/hysteria/config.yaml.tpl b/package/templates/hysteria/config.yaml.tpl new file mode 100644 index 0000000..ec08f31 --- /dev/null +++ b/package/templates/hysteria/config.yaml.tpl @@ -0,0 +1,30 @@ +listen: :{{HYSTERIA_PORT}} + +tls: + cert: /etc/hysteria/server.crt + key: /etc/hysteria/server.key + +auth: + type: http + http: + url: http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth + insecure: true + +obfs: + type: salamander + salamander: + password: "{{HYSTERIA_OBFS_PASSWORD}}" + +bandwidth: + up: "{{BANDWIDTH_UP}}" + down: "{{BANDWIDTH_DOWN}}" + +trafficStats: + listen: 127.0.0.1:{{HYSTERIA_API_PORT}} + secret: "{{HYSTERIA_API_SECRET}}" + +quic: + initStreamReceiveWindow: 8388608 + maxStreamReceiveWindow: 8388608 + initConnReceiveWindow: 20971520 + maxConnReceiveWindow: 20971520 diff --git a/package/templates/nftables/hy2xs.nft.tpl b/package/templates/nftables/hy2xs.nft.tpl new file mode 100644 index 0000000..39c2bfd --- /dev/null +++ b/package/templates/nftables/hy2xs.nft.tpl @@ -0,0 +1,14 @@ +table inet hy2xs { + chain input { + type filter hook input priority 0; policy drop; + + iif lo accept + ct state established,related accept + tcp dport {{SSH_PORT}} accept + udp dport {{HYSTERIA_PORT}} accept + tcp dport {{UI_PORT}} ip saddr 127.0.0.1 accept + icmp type echo-request accept + ip6 nexthdr ipv6-icmp accept + ip protocol icmp accept + } +} diff --git a/tools/build/README.ru.md b/tools/build/README.ru.md new file mode 100644 index 0000000..e10d5a2 --- /dev/null +++ b/tools/build/README.ru.md @@ -0,0 +1,89 @@ +# HY2XS production builder + +## Назначение + +`tools/build/build.sh` собирает один переносимый install package HY2XS для production-развёртывания. + +Итоговый артефакт создаётся в `dist/hy2xs-install-.tar.gz` и предназначен для установки на чистый Debian 12 amd64 без target-side сборки. + +## Поддерживаемая среда сборки + +Builder поддерживает только: + +- Debian 12; +- amd64 / x86_64; +- bash; +- доступ к интернету для установки build-зависимостей и toolchain. + +Windows/macOS не являются production build host. На Windows можно править исходники, но финальную сборку нужно выполнять на Debian 12 amd64. + +## Что builder делает сам + +При запуске builder: + +1. Проверяет ОС и архитектуру build host. +2. Проверяет структуру репозитория. +3. Доставляет отсутствующие системные build-зависимости через `apt-get`. +4. Проверяет и при необходимости скачивает локальный toolchain: + - Go `1.21.13`; + - Bun `1.1.45`; + - Node.js `20.19.0`; + - pnpm `9.15.9`. +5. Собирает install-only orchestrator под Linux amd64. +6. Собирает bundled HY2XS admin под Linux amd64. +7. Формирует metadata и checksums. +8. Создаёт архив install package. +9. Проверяет состав итогового архива. + +## Запуск + +Из корня репозитория: + +```bash +./tools/build/build.sh +``` + +С явной версией пакета: + +```bash +PACKAGE_VERSION=0.1.0 ./tools/build/build.sh +``` + +С явным build id: + +```bash +PACKAGE_VERSION=0.1.0 BUILD_ID=prod-20260425-001 ./tools/build/build.sh +``` + +## Локальный toolchain + +Builder ставит управляемый toolchain в `.toolchain/` и не требует ручной установки Go/Bun/Node/pnpm в систему. + +Если нужная версия уже установлена глобально, builder может использовать её. Если версия не совпадает, будет скачана локальная версия. + +## Важные ограничения + +- Builder не ставит HY2XS на сервер. +- Builder не выполняет target install. +- Builder не собирает ничего на target machine. +- Builder не вендорит Hysteria2 binary в пакет: Hysteria2 скачивается install layer'ом с official upstream. +- Итоговый package не должен содержать build scripts, `.toolchain` или временные каталоги. + +## Результат + +После успешной сборки появится архив: + +```bash +dist/hy2xs-install-.tar.gz +``` + +Его нужно перенести на target Debian 12 amd64, распаковать и запустить `install.sh` от root. + +## Диагностика + +Если сборка падает: + +1. Проверьте, что host — Debian 12 amd64. +2. Проверьте доступ к `go.dev`, `github.com`, `nodejs.org`, npm registry и apt repositories. +3. Удалите `.toolchain/` и повторите запуск, если toolchain скачался повреждённым. +4. Проверьте lock-файлы `orchestrator/bun.lock`, `apps/frontend/pnpm-lock.yaml`, `apps/go.sum`. diff --git a/tools/build/build.sh b/tools/build/build.sh new file mode 100644 index 0000000..eea4dce --- /dev/null +++ b/tools/build/build.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BUILD_DIR="$ROOT_DIR/tools/build" + +# shellcheck source=tools/build/lib/common.sh +. "$BUILD_DIR/lib/common.sh" +# shellcheck source=tools/build/lib/deps.sh +. "$BUILD_DIR/lib/deps.sh" +# shellcheck source=tools/build/lib/verify.sh +. "$BUILD_DIR/lib/verify.sh" +# shellcheck source=tools/build/lib/package.sh +. "$BUILD_DIR/lib/package.sh" + +main() { + cd "$ROOT_DIR" + + require_linux_debian12_amd64 + require_repo_layout + ensure_build_dependencies + ensure_toolchain + + PACKAGE_VERSION="${PACKAGE_VERSION:-0.1.0}" + BUILD_ID="${BUILD_ID:-$(date -u +%Y%m%dT%H%M%SZ)}" + + log_step "Preparing package stage" + prepare_stage "$PACKAGE_VERSION" "$BUILD_ID" + + log_step "Building install-only orchestrator" + build_orchestrator + + log_step "Bundling HY2XS admin fork" + bundle_ui + + log_step "Writing metadata" + write_metadata "$PACKAGE_VERSION" "$BUILD_ID" + + log_step "Creating final archive" + create_archive "$PACKAGE_VERSION" + + log_step "Verifying final archive" + verify_archive "$PACKAGE_VERSION" + + log_info "Built dist/hy2xs-install-${PACKAGE_VERSION}.tar.gz" +} + +main "$@" diff --git a/tools/build/lib/common.sh b/tools/build/lib/common.sh new file mode 100644 index 0000000..0063f36 --- /dev/null +++ b/tools/build/lib/common.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +log_info() { + printf '[hy2xs-build] %s\n' "$*" +} + +log_step() { + printf '\n[hy2xs-build] ==> %s\n' "$*" +} + +fail() { + printf '[hy2xs-build] ERROR: %s\n' "$*" >&2 + exit 1 +} + +require_tool() { + local tool="$1" + command -v "$tool" >/dev/null 2>&1 || fail "required tool not found: $tool" +} + +version_ge() { + [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n 1)" = "$2" ] +} + +copy_dir_contents() { + local src="$1" + local dst="$2" + + mkdir -p "$dst" + if [ -d "$src" ]; then + cp -a "$src"/. "$dst"/ + else + fail "directory not found: $src" + fi +} + +download_file() { + local url="$1" + local dst="$2" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$dst" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$dst" "$url" + else + fail "curl or wget is required to download $url" + fi +} diff --git a/tools/build/lib/deps.sh b/tools/build/lib/deps.sh new file mode 100644 index 0000000..7227c91 --- /dev/null +++ b/tools/build/lib/deps.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +set -euo pipefail + +GO_REQUIRED="${GO_REQUIRED:-1.21.13}" +BUN_REQUIRED="${BUN_REQUIRED:-1.1.45}" +NODE_REQUIRED="${NODE_REQUIRED:-20.19.0}" +PNPM_REQUIRED="${PNPM_REQUIRED:-9.15.9}" +TOOLCHAIN_DIR="${TOOLCHAIN_DIR:-$ROOT_DIR/.toolchain}" + +require_linux_debian12_amd64() { + [ "$(uname -s)" = "Linux" ] || fail "production builder supports only Linux Debian 12 amd64" + + local arch + arch="$(uname -m)" + [ "$arch" = "x86_64" ] || [ "$arch" = "amd64" ] || fail "production builder supports only amd64, got: $arch" + + [ -f /etc/os-release ] || fail "missing /etc/os-release" + # shellcheck disable=SC1091 + . /etc/os-release + [ "${ID:-}" = "debian" ] || fail "production builder supports only Debian 12, got: ${ID:-unknown}" + [ "${VERSION_ID:-}" = "12" ] || fail "production builder supports only Debian 12, got version: ${VERSION_ID:-unknown}" +} + +apt_install_missing() { + local missing=() + local pkg + for pkg in "$@"; do + if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q 'install ok installed'; then + missing+=("$pkg") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + return 0 + fi + + log_info "Installing missing build packages: ${missing[*]}" + if [ "$(id -u)" = "0" ]; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${missing[@]}" + elif command -v sudo >/dev/null 2>&1; then + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${missing[@]}" + else + fail "missing packages (${missing[*]}) and neither root nor sudo is available" + fi +} + +ensure_build_dependencies() { + apt_install_missing \ + ca-certificates curl unzip tar xz-utils git build-essential pkg-config \ + bash coreutils findutils grep sed gawk openssl + + require_tool curl + require_tool tar + require_tool xz + require_tool unzip + require_tool sha256sum + require_tool find + require_tool install + require_tool sort + require_tool xargs + require_tool grep + require_tool sed +} + +go_version() { + "$1" version | sed -E 's/^go version go([0-9.]+).*/\1/' +} + +ensure_go() { + local managed="$TOOLCHAIN_DIR/go/bin/go" + if [ -x "$managed" ] && [ "$(go_version "$managed")" = "$GO_REQUIRED" ]; then + GO_BIN="$managed" + elif command -v go >/dev/null 2>&1 && [ "$(go_version "$(command -v go)")" = "$GO_REQUIRED" ]; then + GO_BIN="$(command -v go)" + else + log_info "Installing Go $GO_REQUIRED into $TOOLCHAIN_DIR/go" + mkdir -p "$TOOLCHAIN_DIR/downloads" + local archive="$TOOLCHAIN_DIR/downloads/go${GO_REQUIRED}.linux-amd64.tar.gz" + download_file "https://go.dev/dl/go${GO_REQUIRED}.linux-amd64.tar.gz" "$archive" + rm -rf "$TOOLCHAIN_DIR/go" + tar -C "$TOOLCHAIN_DIR" -xzf "$archive" + GO_BIN="$managed" + fi + + export GO_BIN + export GOROOT="$TOOLCHAIN_DIR/go" + export PATH="$(dirname "$GO_BIN"):$PATH" + export GOTOOLCHAIN=local + [ "$(go_version "$GO_BIN")" = "$GO_REQUIRED" ] || fail "Go version mismatch: required $GO_REQUIRED, got $($GO_BIN version)" +} + +ensure_bun() { + local managed="$TOOLCHAIN_DIR/bun/bin/bun" + if [ -x "$managed" ] && [ "$($managed --version)" = "$BUN_REQUIRED" ]; then + BUN_BIN="$managed" + elif command -v bun >/dev/null 2>&1 && [ "$(bun --version)" = "$BUN_REQUIRED" ]; then + BUN_BIN="$(command -v bun)" + else + log_info "Installing Bun $BUN_REQUIRED into $TOOLCHAIN_DIR/bun" + mkdir -p "$TOOLCHAIN_DIR/downloads" "$TOOLCHAIN_DIR/bun" + local archive="$TOOLCHAIN_DIR/downloads/bun-linux-x64-${BUN_REQUIRED}.zip" + download_file "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_REQUIRED}/bun-linux-x64.zip" "$archive" + rm -rf "$TOOLCHAIN_DIR/bun-tmp" "$TOOLCHAIN_DIR/bun" + mkdir -p "$TOOLCHAIN_DIR/bun-tmp" + unzip -q "$archive" -d "$TOOLCHAIN_DIR/bun-tmp" + mkdir -p "$TOOLCHAIN_DIR/bun/bin" + install -m 0755 "$TOOLCHAIN_DIR/bun-tmp/bun-linux-x64/bun" "$managed" + rm -rf "$TOOLCHAIN_DIR/bun-tmp" + BUN_BIN="$managed" + fi + + export BUN_BIN + export PATH="$(dirname "$BUN_BIN"):$PATH" + [ "$($BUN_BIN --version)" = "$BUN_REQUIRED" ] || fail "Bun version mismatch: required $BUN_REQUIRED, got $($BUN_BIN --version)" +} + +node_version() { + "$1" --version | sed 's/^v//' +} + +ensure_node() { + local managed="$TOOLCHAIN_DIR/node/bin/node" + if [ -x "$managed" ] && [ "$(node_version "$managed")" = "$NODE_REQUIRED" ]; then + NODE_BIN="$managed" + elif command -v node >/dev/null 2>&1 && [ "$(node_version "$(command -v node)")" = "$NODE_REQUIRED" ]; then + NODE_BIN="$(command -v node)" + else + log_info "Installing Node.js $NODE_REQUIRED into $TOOLCHAIN_DIR/node" + mkdir -p "$TOOLCHAIN_DIR/downloads" + local archive="$TOOLCHAIN_DIR/downloads/node-v${NODE_REQUIRED}-linux-x64.tar.xz" + download_file "https://nodejs.org/dist/v${NODE_REQUIRED}/node-v${NODE_REQUIRED}-linux-x64.tar.xz" "$archive" + rm -rf "$TOOLCHAIN_DIR/node" "$TOOLCHAIN_DIR/node-v${NODE_REQUIRED}-linux-x64" + tar -C "$TOOLCHAIN_DIR" -xJf "$archive" + mv "$TOOLCHAIN_DIR/node-v${NODE_REQUIRED}-linux-x64" "$TOOLCHAIN_DIR/node" + NODE_BIN="$managed" + fi + + export NODE_BIN + export PATH="$(dirname "$NODE_BIN"):$PATH" + [ "$(node_version "$NODE_BIN")" = "$NODE_REQUIRED" ] || fail "Node.js version mismatch: required $NODE_REQUIRED, got $($NODE_BIN --version)" +} + +ensure_pnpm() { + local managed="$TOOLCHAIN_DIR/pnpm/bin/pnpm" + if [ -x "$managed" ] && [ "$($managed --version)" = "$PNPM_REQUIRED" ]; then + PNPM_BIN="$managed" + else + log_info "Installing pnpm $PNPM_REQUIRED into $TOOLCHAIN_DIR/pnpm" + mkdir -p "$TOOLCHAIN_DIR/pnpm/bin" + corepack enable --install-directory "$TOOLCHAIN_DIR/pnpm/bin" + corepack prepare "pnpm@$PNPM_REQUIRED" --activate + if command -v pnpm >/dev/null 2>&1; then + cp "$(command -v pnpm)" "$managed" + chmod 0755 "$managed" + fi + PNPM_BIN="$(command -v pnpm)" + fi + + export PNPM_BIN + [ "$($PNPM_BIN --version)" = "$PNPM_REQUIRED" ] || fail "pnpm version mismatch: required $PNPM_REQUIRED, got $($PNPM_BIN --version)" +} + +ensure_toolchain() { + ensure_go + ensure_bun + ensure_node + ensure_pnpm + + log_info "Go: $($GO_BIN version)" + log_info "Bun: $($BUN_BIN --version)" + log_info "Node.js: $($NODE_BIN --version)" + log_info "pnpm: $($PNPM_BIN --version)" +} diff --git a/tools/build/lib/package.sh b/tools/build/lib/package.sh new file mode 100644 index 0000000..dbd809a --- /dev/null +++ b/tools/build/lib/package.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +STAGE_DIR="tools/build/output/hy2xs-install" +ADMIN_BUILD_DIR="tools/build/output/hy2xs-admin-build" + +prepare_stage() { + local version="$1" + local build_id="$2" + + rm -rf "$STAGE_DIR" + mkdir -p "$STAGE_DIR" "dist" + copy_dir_contents "package" "$STAGE_DIR" + + rm -rf "$STAGE_DIR/orchestrator" "$STAGE_DIR/ui" "$STAGE_DIR/metadata" + mkdir -p "$STAGE_DIR/orchestrator" "$STAGE_DIR/ui/hy2xs-admin" "$STAGE_DIR/metadata" + + printf '%s\n' "$version" >"$STAGE_DIR/metadata/package.version" + printf '%s\n' "$build_id" >"$STAGE_DIR/metadata/package.build_id" +} + +build_orchestrator() { + ( + cd orchestrator + "$BUN_BIN" install --frozen-lockfile + "$BUN_BIN" build src/cli.ts --compile --target=bun-linux-x64 --outfile ../"$STAGE_DIR"/orchestrator/hy2xs-orchestrator + ) + chmod 0755 "$STAGE_DIR/orchestrator/hy2xs-orchestrator" +} + +bundle_ui() { + local ui_src="${UI_SRC:-apps}" + + rm -rf "$ADMIN_BUILD_DIR" + mkdir -p "$ADMIN_BUILD_DIR" + + ( + cd "$ui_src/frontend" + "$PNPM_BIN" install --frozen-lockfile + "$PNPM_BIN" run build:prod + ) + + ( + cd "$ui_src" + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 GOTOOLCHAIN=local "$GO_BIN" mod download + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 GOTOOLCHAIN=local "$GO_BIN" build -trimpath -ldflags "-s -w" -o "../$ADMIN_BUILD_DIR/hy2xs-admin" . + ) + + install -m 0755 "$ADMIN_BUILD_DIR/hy2xs-admin" "$STAGE_DIR/ui/hy2xs-admin/hy2xs-admin" + if [ -f "$ui_src/docs/sql/h_ui_db.sql" ]; then + mkdir -p "$STAGE_DIR/ui/hy2xs-admin/docs/sql" + install -m 0644 "$ui_src/docs/sql/h_ui_db.sql" "$STAGE_DIR/ui/hy2xs-admin/docs/sql/h_ui_db.sql" + fi +} + +write_metadata() { + local version="$1" + local build_id="$2" + + { + printf 'name=HY2XS\n' + printf 'version=%s\n' "$version" + printf 'build_id=%s\n' "$build_id" + printf 'build_host_os=debian12\n' + printf 'build_host_arch=amd64\n' + printf 'target_os=linux\n' + printf 'target_arch=amd64\n' + printf 'orchestrator_stack=Bun+TypeScript\n' + printf 'go_version=%s\n' "$($GO_BIN version)" + printf 'bun_version=%s\n' "$($BUN_BIN --version)" + printf 'node_version=%s\n' "$($NODE_BIN --version)" + printf 'pnpm_version=%s\n' "$($PNPM_BIN --version)" + printf 'hysteria_source=official-upstream\n' + printf 'hysteria_target=linux-amd64\n' + } >"$STAGE_DIR/metadata/package.env" + + ( + cd "$STAGE_DIR" + find . -type f ! -path './metadata/checksums.txt' -print0 \ + | sort -z \ + | xargs -0 sha256sum >metadata/checksums.txt + ) +} + +create_archive() { + local version="$1" + local archive="dist/hy2xs-install-${version}.tar.gz" + + rm -f "$archive" + tar -C "tools/build/output" -czf "$archive" "hy2xs-install" +} diff --git a/tools/build/lib/verify.sh b/tools/build/lib/verify.sh new file mode 100644 index 0000000..1e6b712 --- /dev/null +++ b/tools/build/lib/verify.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +require_repo_layout() { + [ -d "orchestrator/src" ] || fail "missing orchestrator/src" + [ -f "orchestrator/package.json" ] || fail "missing orchestrator/package.json" + [ -f "orchestrator/bun.lock" ] || fail "missing orchestrator/bun.lock" + [ -f "package/install.sh" ] || fail "missing package/install.sh" + [ -d "package/templates" ] || fail "missing package/templates" + [ -f "package/templates/env/post-install.env.tpl" ] || fail "missing package/templates/env/post-install.env.tpl" + [ -d "package/systemd" ] || fail "missing package/systemd" + [ -d "apps" ] || fail "missing apps HY2XS admin source" + [ -f "apps/go.mod" ] || fail "missing apps/go.mod" + [ -f "apps/go.sum" ] || fail "missing apps/go.sum" + [ -f "apps/frontend/package.json" ] || fail "missing apps/frontend/package.json" + [ -f "apps/frontend/pnpm-lock.yaml" ] || fail "missing apps/frontend/pnpm-lock.yaml" +} + +verify_archive() { + local version="$1" + local archive="dist/hy2xs-install-${version}.tar.gz" + + [ -f "$archive" ] || fail "archive was not created: $archive" + + local listing + listing="$(tar -tzf "$archive")" + + printf '%s\n' "$listing" | grep -q '^hy2xs-install/install.sh$' || fail "archive missing install.sh" + printf '%s\n' "$listing" | grep -q '^hy2xs-install/orchestrator/hy2xs-orchestrator$' || fail "archive missing orchestrator artifact" + printf '%s\n' "$listing" | grep -q '^hy2xs-install/ui/hy2xs-admin/hy2xs-admin$' || fail "archive missing bundled UI binary" + printf '%s\n' "$listing" | grep -q '^hy2xs-install/systemd/hysteria-server.service$' || fail "archive missing hysteria systemd unit" + printf '%s\n' "$listing" | grep -q '^hy2xs-install/systemd/hy2xs-admin.service$' || fail "archive missing admin systemd unit" + printf '%s\n' "$listing" | grep -q '^hy2xs-install/templates/hysteria/config.yaml.tpl$' || fail "archive missing Hysteria config template" + printf '%s\n' "$listing" | grep -q '^hy2xs-install/templates/env/post-install.env.tpl$' || fail "archive missing post-install env template" + printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/checksums.txt$' || fail "archive missing checksums" +}