From 12c65c8e31d1dc949ec54bfb77f44a8fc06a4130 Mon Sep 17 00:00:00 2001 From: Crimson Date: Tue, 28 Apr 2026 04:45:43 +0500 Subject: [PATCH] fix(fix3): runtime env hardening and public endpoint source-of-truth --- apps/controller/config.go | 152 +----------------- apps/controller/hysteria2.go | 13 +- apps/frontend/src/api/hysteria2/types.ts | 2 - apps/frontend/src/types/env.d.ts | 2 - apps/frontend/src/utils/request.ts | 3 +- .../frontend/src/views/account/list/index.vue | 3 - .../frontend/src/views/info/account/index.vue | 4 - apps/frontend/vite.config.ts | 11 +- apps/model/dto/hysteria2.go | 2 - apps/service/hysteria2_api.go | 53 +++--- docs/03-server-hysteria2.md | 2 + docs/05-client-and-access-scope.md | 4 + docs/11-testing-and-acceptance.md | 3 + orchestrator/src/cli.ts | 10 +- orchestrator/src/commands/install.ts | 15 +- orchestrator/src/commands/reconfigure.ts | 7 +- orchestrator/src/config/env.ts | 13 +- orchestrator/src/steps/config.ts | 2 +- orchestrator/src/steps/preflight.ts | 30 +++- orchestrator/src/steps/smoke.ts | 13 +- orchestrator/src/types/context.ts | 6 +- package/config/hy2xs.env | 15 +- package/systemd/hy2xs-admin.service | 4 +- 23 files changed, 131 insertions(+), 238 deletions(-) diff --git a/apps/controller/config.go b/apps/controller/config.go index b7e3b3d..afec475 100644 --- a/apps/controller/config.go +++ b/apps/controller/config.go @@ -5,7 +5,6 @@ import ( "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" @@ -15,8 +14,6 @@ import ( "hy2xs-admin/service" "hy2xs-admin/util" "io" - "os" - "path/filepath" "strconv" "strings" "time" @@ -167,24 +164,7 @@ func GetHysteria2Config(c *gin.Context) { } func UpdateHysteria2Config(c *gin.Context) { - hysteria2ServerConfig, err := validateField(c, bo.Hysteria2ServerConfig{}) - if err != nil { - return - } - - if err = service.UpdateHysteria2Config(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) + vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c) } func ExportHysteria2Config(c *gin.Context) { @@ -253,84 +233,7 @@ func ExportHysteria2Config(c *gin.Context) { } 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.Hysteria2TrafficStatsSecret}) - if err != nil { - vo.Fail(err.Error(), c) - return - } - - var hUIWebPort string - var trafficStatsSecret string - for _, item := range config { - if *item.Key == constant.HUIWebPort { - hUIWebPort = *item.Value - } else if *item.Key == constant.Hysteria2TrafficStatsSecret { - trafficStatsSecret = *item.Value - } - } - - if hUIWebPort == "" || trafficStatsSecret == "" { - logrus.Errorf("hUIWebPort or trafficStatsSecret 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 = &trafficStatsSecret - - 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) + vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c) } func ExportConfig(c *gin.Context) { @@ -401,56 +304,9 @@ func Hysteria2AcmePath(c *gin.Context) { } func RestartServer(c *gin.Context) { - go func() { - _ = service.StopServer() - }() - vo.Success(nil, c) + vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", 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) + vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c) } diff --git a/apps/controller/hysteria2.go b/apps/controller/hysteria2.go index eab7e15..a0d70cb 100644 --- a/apps/controller/hysteria2.go +++ b/apps/controller/hysteria2.go @@ -64,7 +64,7 @@ func Hysteria2Url(c *gin.Context) { return } - url, err := service.Hysteria2Url(*hysteria2UrlDto.AccountId, *hysteria2UrlDto.Hostname) + url, err := service.Hysteria2Url(*hysteria2UrlDto.AccountId) if err != nil { vo.Fail(err.Error(), c) return @@ -88,8 +88,7 @@ func Hysteria2SubscribeUrl(c *gin.Context) { return } subscribeUrl, err := service.Hysteria2SubscribeUrl(*hysteria2SubscribeUrlDto.AccountId, - *hysteria2SubscribeUrlDto.Protocol, - *hysteria2SubscribeUrlDto.Host) + *hysteria2SubscribeUrlDto.Protocol) if err != nil { vo.Fail(err.Error(), c) return @@ -114,12 +113,6 @@ func Hysteria2Subscribe(c *gin.Context) { 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) { @@ -134,7 +127,7 @@ func Hysteria2Subscribe(c *gin.Context) { clientType = constant.Clash } - userInfo, configStr, err := service.Hysteria2Subscribe(conPass, clientType, host) + userInfo, configStr, err := service.Hysteria2Subscribe(conPass, clientType) if err != nil { vo.Fail(err.Error(), c) return diff --git a/apps/frontend/src/api/hysteria2/types.ts b/apps/frontend/src/api/hysteria2/types.ts index 49f25ad..e9032f1 100644 --- a/apps/frontend/src/api/hysteria2/types.ts +++ b/apps/frontend/src/api/hysteria2/types.ts @@ -6,12 +6,10 @@ export interface Hysteria2KickDto { export interface Hysteria2SubscribeUrlDto { accountId: number; protocol: string; - host: string; } export interface Hysteria2UrlDto { accountId: number; - hostname: string; } export interface Hysteria2SubscribeVo { diff --git a/apps/frontend/src/types/env.d.ts b/apps/frontend/src/types/env.d.ts index c23ee6c..7196a6b 100644 --- a/apps/frontend/src/types/env.d.ts +++ b/apps/frontend/src/types/env.d.ts @@ -9,8 +9,6 @@ declare module "*.vue" { // TypeScript-подсказки для переменных окружения interface ImportMetaEnv { - VITE_APP_PORT: string; - VITE_APP_BASE_API: string; } interface ImportMeta { diff --git a/apps/frontend/src/utils/request.ts b/apps/frontend/src/utils/request.ts index a2d0d99..04f3831 100644 --- a/apps/frontend/src/utils/request.ts +++ b/apps/frontend/src/utils/request.ts @@ -2,9 +2,10 @@ import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios"; import { useAccountStoreHook } from "@/store/modules/account"; const dynamicBase = (window as any).__dynamic_base__ || ""; +const API_BASE = "/hui"; // Создание axios instance const service = axios.create({ - baseURL: `${dynamicBase}${import.meta.env.VITE_APP_BASE_API}`, + baseURL: `${dynamicBase}${API_BASE}`, timeout: 50000, headers: { "Content-Type": "application/json;charset=utf-8" }, }); diff --git a/apps/frontend/src/views/account/list/index.vue b/apps/frontend/src/views/account/list/index.vue index beb5790..d7cfd5a 100644 --- a/apps/frontend/src/views/account/list/index.vue +++ b/apps/frontend/src/views/account/list/index.vue @@ -906,7 +906,6 @@ const handleSubscribe = async (row: { [key: string]: any }) => { const dto: Hysteria2SubscribeUrlDto = { accountId: row.id, protocol: window.location.protocol, - host: window.location.host, }; const { data } = await hysteria2SubscribeUrlApi(dto); copy(data.url); @@ -920,7 +919,6 @@ const handleNodeUrl = async (row: { [key: string]: any }) => { try { const dto: Hysteria2UrlDto = { accountId: row.id, - hostname: window.location.hostname, }; const { data } = await hysteria2UrlApi(dto); copy(data.url); @@ -934,7 +932,6 @@ const handleQrCode = async (row: { [key: string]: any }) => { try { const dto: Hysteria2UrlDto = { accountId: row.id, - hostname: window.location.hostname, }; const { data } = await hysteria2UrlApi(dto); state.qrCodeSrc = "data:image/png;base64," + data.qrCode; diff --git a/apps/frontend/src/views/info/account/index.vue b/apps/frontend/src/views/info/account/index.vue index a879672..6f70a6d 100644 --- a/apps/frontend/src/views/info/account/index.vue +++ b/apps/frontend/src/views/info/account/index.vue @@ -204,7 +204,6 @@ const handleSubscribe = async () => { const dto: Hysteria2SubscribeUrlDto = { accountId: accountStore.id, protocol: window.location.protocol, - host: window.location.host, }; const { data } = await hysteria2SubscribeUrlApi(dto); copy(data.url); @@ -219,7 +218,6 @@ const handleSubscribeQrCode = async () => { const dto: Hysteria2SubscribeUrlDto = { accountId: accountStore.id, protocol: window.location.protocol, - host: window.location.host, }; const { data } = await hysteria2SubscribeUrlApi(dto); state.qrCodeSrc = "data:image/png;base64," + data.qrCode; @@ -233,7 +231,6 @@ const handleNodeUrl = async () => { try { const dto: Hysteria2UrlDto = { accountId: accountStore.id, - hostname: window.location.hostname, }; const { data } = await hysteria2UrlApi(dto); copy(data.url); @@ -247,7 +244,6 @@ const handleUrlQrCode = async () => { try { const dto: Hysteria2UrlDto = { accountId: accountStore.id, - hostname: window.location.hostname, }; const { data } = await hysteria2UrlApi(dto); state.qrCodeSrc = "data:image/png;base64," + data.qrCode; diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index c74cc80..15cfccd 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -1,6 +1,6 @@ import vue from "@vitejs/plugin-vue"; -import { ConfigEnv, defineConfig, loadEnv, UserConfig } from "vite"; +import { defineConfig, UserConfig } from "vite"; import AutoImport from "unplugin-auto-import/vite"; import Components from "unplugin-vue-components/vite"; @@ -20,9 +20,10 @@ 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; +const DEV_SERVER_PORT = 8080; +const API_BASE = "/hui"; -export default defineConfig(({ mode }: ConfigEnv): UserConfig => { - const env = loadEnv(mode, process.cwd()); +export default defineConfig((): UserConfig => { return { base: "/", resolve: { @@ -44,11 +45,11 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => { }, server: { host: "0.0.0.0", - port: Number(env.VITE_APP_PORT), + port: DEV_SERVER_PORT, open: true, // Automatically open browser on start proxy: { // Reverse proxy for CORS in development - [env.VITE_APP_BASE_API]: { + [API_BASE]: { target: "http://127.0.0.1:8081", changeOrigin: true, }, diff --git a/apps/model/dto/hysteria2.go b/apps/model/dto/hysteria2.go index 834e89f..e4e27ce 100644 --- a/apps/model/dto/hysteria2.go +++ b/apps/model/dto/hysteria2.go @@ -18,10 +18,8 @@ type Hysteria2VersionDto struct { 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/service/hysteria2_api.go b/apps/service/hysteria2_api.go index 37a3fe5..3c37322 100644 --- a/apps/service/hysteria2_api.go +++ b/apps/service/hysteria2_api.go @@ -8,26 +8,27 @@ import ( "hy2xs-admin/model/bo" "hy2xs-admin/model/constant" "hy2xs-admin/proxy" - "net" "net/url" + "os" "strconv" "strings" "time" ) -func parseListenPort(listen string) (int, error) { - host, port, err := net.SplitHostPort(listen) - if err != nil { - return 0, err +func resolvePublicEndpoint() (string, int, error) { + host := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_HOST")) + if host == "" || host == "0.0.0.0" { + return "", 0, errors.New("HY2XS_PUBLIC_HOST must be set to public domain or IPv4") } - if host == "" || port == "" { - return 0, errors.New("invalid listen address") + portRaw := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_PORT")) + if portRaw == "" { + return "", 0, errors.New("HY2XS_PUBLIC_PORT is required") } - value, convErr := strconv.Atoi(port) - if convErr != nil || value < 1 || value > 65535 { - return 0, errors.New("invalid listen port") + port, err := strconv.Atoi(portRaw) + if err != nil || port < 1 || port > 65535 { + return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid TCP port") } - return value, nil + return host, port, nil } func Hysteria2Auth(conPass string) (int64, string, error) { @@ -103,11 +104,15 @@ func Hysteria2Kick(ids []int64, kickUtilTime int64) error { return nil } -func Hysteria2SubscribeUrl(accountId int64, protocol string, host string) (string, error) { +func Hysteria2SubscribeUrl(accountId int64, protocol string) (string, error) { account, err := dao.GetAccount("id = ?", accountId) if err != nil { return "", err } + publicHost, publicPort, err := resolvePublicEndpoint() + if err != nil { + return "", err + } config, err := dao.GetConfig("key = ?", constant.HUIWebContext) if err != nil { return "", err @@ -116,10 +121,10 @@ func Hysteria2SubscribeUrl(accountId int64, protocol string, host string) (strin 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 + return fmt.Sprintf("%s//%s:%d%s/hui/%s", protocol, publicHost, publicPort, webContext, url.QueryEscape(*account.ConPass)), nil } -func Hysteria2Subscribe(conPass string, clientType string, host string) (string, string, error) { +func Hysteria2Subscribe(conPass string, clientType string) (string, string, error) { hysteria2Config, err := GetHysteria2Config() if err != nil { return "", "", err @@ -132,6 +137,10 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string, if err != nil { return "", "", err } + publicHost, publicPort, err := resolvePublicEndpoint() + if err != nil { + return "", "", err + } hysteria2Name := "hysteria2" hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark) @@ -144,11 +153,6 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string, userInfo := "" configStr := "" - listenPort, err := parseListenPort(*hysteria2Config.Listen) - if err != nil { - return "", "", err - } - publicHost := strings.Split(host, ":")[0] if clientType == constant.Shadowrocket || clientType == constant.Clash { userInfo = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", *account.Upload, @@ -160,7 +164,7 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string, Name: hysteria2Name, Type: "hysteria2", Server: publicHost, - Port: strconv.Itoa(listenPort), + Port: strconv.Itoa(publicPort), Password: conPass, } @@ -224,7 +228,7 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string, } } } else if clientType == constant.V2rayN { - hysteria2Url, err := Hysteria2Url(*account.Id, strings.Split(host, ":")[0]) + hysteria2Url, err := Hysteria2Url(*account.Id) if err != nil { return "", "", err } @@ -234,7 +238,7 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string, return userInfo, configStr, nil } -func Hysteria2Url(accountId int64, hostname string) (string, error) { +func Hysteria2Url(accountId int64) (string, error) { hysteria2Config, err := GetHysteria2Config() if err != nil { return "", err @@ -242,13 +246,10 @@ func Hysteria2Url(accountId int64, hostname string) (string, error) { if hysteria2Config.Listen == nil || *hysteria2Config.Listen == "" { return "", errors.New("hysteria2 config is empty") } - port, err := parseListenPort(*hysteria2Config.Listen) + hostname, port, err := resolvePublicEndpoint() if err != nil { return "", err } - if hostname == "" || hostname == "0.0.0.0" { - return "", errors.New("invalid public host") - } account, err := dao.GetAccount("id = ?", accountId) if err != nil { diff --git a/docs/03-server-hysteria2.md b/docs/03-server-hysteria2.md index 7ccc93d..d184e1a 100644 --- a/docs/03-server-hysteria2.md +++ b/docs/03-server-hysteria2.md @@ -68,6 +68,7 @@ Hysteria2 — основной транспортный компонент се Инварианты: - `acme` -> только `acme` block в конфиге; +- `acme` block обязан содержать `type: http|tls|dns` из runtime env (`HY2XS_ACME_TYPE`); - `file` -> только `tls.cert`/`tls.key` block; - `self_signed_dev` -> только dev сценарии. @@ -111,3 +112,4 @@ Hysteria2 — основной транспортный компонент се 7. bundled UI работает поверх актуального состояния сервера 8. `trafficStats.secret` отдельный от `JWT_SECRET` 9. IPv6 listen не используется +10. публичные клиентские endpoint/URL берутся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen`/request-host diff --git a/docs/05-client-and-access-scope.md b/docs/05-client-and-access-scope.md index 3bc9d41..a08c4ae 100644 --- a/docs/05-client-and-access-scope.md +++ b/docs/05-client-and-access-scope.md @@ -35,6 +35,10 @@ В клиентской части используется только `public_host/public_port`. +В runtime-обозначениях HY2XS это эквивалентно: +- `HY2XS_PUBLIC_HOST` +- `HY2XS_PUBLIC_PORT` + Инварианты: - `listen` и `public endpoint` разделены; - в клиентских URL не используется `0.0.0.0`; diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index 9b83f39..1642d85 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -47,6 +47,8 @@ 12. bootstrap admin secret существует и имеет `0600` 13. `trafficStats` API: корректный secret принимает запрос, неверный secret отклоняется 14. TLS mode в `config.yaml` соответствует runtime env (`acme|file|self_signed_dev`) +15. при `HY2XS_TLS_MODE=acme` в `config.yaml` выставлен `acme.type` из `HY2XS_ACME_TYPE` +16. subscribe/node URL в API/QR формируются по `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT` 15. `nft -c -f /etc/nftables.conf` проходит после apply ## D. Negative tests @@ -76,3 +78,4 @@ 8. Telegram/access layer не требуется для прохождения install acceptance 9. отсутствует production path для port hopping 10. UI не запускается от root +11. клиентские endpoint не зависят от request `Host`/`hostname` diff --git a/orchestrator/src/cli.ts b/orchestrator/src/cli.ts index ecaed26..335f201 100644 --- a/orchestrator/src/cli.ts +++ b/orchestrator/src/cli.ts @@ -21,7 +21,8 @@ function takeValue(args: string[], index: number, flag: string): string { function parseInstallOptions(args: string[]): InstallOptions { const options: InstallOptions = { packageDir: "", - configPath: "/etc/hy2xs/hy2xs.env", + sourceConfigPath: "", + runtimeConfigPath: "/etc/hy2xs/hy2xs.env", nonInteractive: false, skipFirewall: false, skipStart: false @@ -44,7 +45,7 @@ function parseInstallOptions(args: string[]): InstallOptions { options.skipStart = true; break; case "--config": - options.configPath = takeValue(args, i, arg); + options.sourceConfigPath = takeValue(args, i, arg); i += 1; break; default: @@ -64,7 +65,8 @@ function parseInstallOptions(args: string[]): InstallOptions { function parseReconfigureOptions(args: string[]): ReconfigureOptions { const options: ReconfigureOptions = { packageDir: "", - configPath: "/etc/hy2xs/hy2xs.env", + sourceConfigPath: "/etc/hy2xs/hy2xs.env", + runtimeConfigPath: "/etc/hy2xs/hy2xs.env", nonInteractive: false, dryRun: false, apply: false, @@ -80,7 +82,7 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions { i += 1; break; case "--config": - options.configPath = takeValue(args, i, arg); + options.sourceConfigPath = takeValue(args, i, arg); i += 1; break; case "--dry-run": diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index 329943c..7ae7508 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -28,8 +28,11 @@ function secret(): string { } export async function install(options: InstallOptions): Promise { - const hasConfig = await exists(options.configPath); - const sourceConfigPath = hasConfig ? options.configPath : `${options.packageDir}/config/hy2xs.env`; + const hasSourceConfig = options.sourceConfigPath ? await exists(options.sourceConfigPath) : false; + if (options.sourceConfigPath && !hasSourceConfig) { + throw new Error(`config source not found: ${options.sourceConfigPath}`); + } + const sourceConfigPath = hasSourceConfig ? options.sourceConfigPath : `${options.packageDir}/config/hy2xs.env`; const sourceConfigRaw = await readText(sourceConfigPath); const config = parseRuntimeEnv(sourceConfigRaw); @@ -49,11 +52,9 @@ export async function install(options: InstallOptions): Promise { await installDeps(context); step("filesystem"); await prepareFilesystem(context); - if (!hasConfig) { - step("write runtime env"); - await runVisible`mkdir -p /etc/hy2xs`; - await writeText(options.configPath, renderRuntimeEnv(config), 0o600); - } + step("write runtime env"); + await runVisible`mkdir -p /etc/hy2xs`; + await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600); step("bundled UI"); await deployUi(context); step("Hysteria2 upstream install"); diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index b443629..17ff8a2 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -28,7 +28,7 @@ async function rollbackCurrentState(): Promise { } export async function reconfigure(options: ReconfigureOptions): Promise { - const configRaw = await readText(options.configPath); + const configRaw = await readText(options.sourceConfigPath); const config = parseRuntimeEnv(configRaw); const context: ReconfigureContext & { packageVersion: string; packageBuildId: string; installDate: string; hysteriaAuthPassword: string; hysteriaVersion: string } = { @@ -46,7 +46,8 @@ export async function reconfigure(options: ReconfigureOptions): Promise { if (options.dryRun) { info("reconfigure dry-run: validated config and execution graph"); - info(`config file: ${options.configPath}`); + info(`config source: ${options.sourceConfigPath}`); + info(`runtime file: ${options.runtimeConfigPath}`); info(`ui bind: ${config.uiBindHost}:${config.uiPort}`); info(`hysteria bind: ${config.hysteriaBindHost}:${config.hysteriaPort}`); info(`public endpoint: ${config.publicHost}:${config.publicPort}`); @@ -64,7 +65,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise { step("firewall"); await applyFirewall(context); step("write env artifacts"); - await writeText(options.configPath, renderRuntimeEnv(config), 0o600); + await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600); await writePostInstallEnv(context); step("smoke checks"); await smoke(context); diff --git a/orchestrator/src/config/env.ts b/orchestrator/src/config/env.ts index 3cc8cad..81d0780 100644 --- a/orchestrator/src/config/env.ts +++ b/orchestrator/src/config/env.ts @@ -7,6 +7,13 @@ function randomSecret(): string { return randomBytes(24).toString("base64url"); } +function valueOrGenerate(value: string | undefined): string { + if (!value || value === "__GENERATE__") { + return randomSecret(); + } + return value; +} + function parseEnv(content: string): EnvMap { const result: EnvMap = {}; for (const rawLine of content.split(/\r?\n/)) { @@ -111,7 +118,7 @@ export function parseRuntimeEnv(content: string): RuntimeConfig { uiBindHost, uiPort, adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"), - adminInitialPassword: env.HY2XS_ADMIN_INITIAL_PASSWORD || randomSecret(), + adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD), forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, true), tlsMode, acmeType, @@ -127,9 +134,9 @@ export function parseRuntimeEnv(content: string): RuntimeConfig { env.HY2XS_HYSTERIA_TRAFFIC_STATS_HOST || "127.0.0.1" ), hysteriaTrafficStatsPort: trafficStatsPort, - hysteriaTrafficStatsSecret: env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET || randomSecret(), + hysteriaTrafficStatsSecret: valueOrGenerate(env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET), hysteriaObfsType: "salamander", - hysteriaObfsPassword: requireValue("HY2XS_HYSTERIA_OBFS_PASSWORD", env.HY2XS_HYSTERIA_OBFS_PASSWORD || randomSecret()), + hysteriaObfsPassword: requireValue("HY2XS_HYSTERIA_OBFS_PASSWORD", valueOrGenerate(env.HY2XS_HYSTERIA_OBFS_PASSWORD)), hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps", hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps", hysteriaIgnoreClientBandwidth: parseBool( diff --git a/orchestrator/src/steps/config.ts b/orchestrator/src/steps/config.ts index c2c9153..8092207 100644 --- a/orchestrator/src/steps/config.ts +++ b/orchestrator/src/steps/config.ts @@ -4,7 +4,7 @@ import { runVisible } from "../lib/process"; export async function generateConfig(context: InstallContext): Promise { const tlsAcmeBlock = context.config.tlsMode === "acme" - ? `acme:\n domains:\n - ${context.config.domain}\n email: ${context.config.acmeEmail}\n ca: letsencrypt\n dir: /var/lib/hysteria/acme\n listenHost: 0.0.0.0` + ? `acme:\n domains:\n - ${context.config.domain}\n email: ${context.config.acmeEmail}\n ca: letsencrypt\n dir: /var/lib/hysteria/acme\n listenHost: 0.0.0.0\n type: ${context.config.acmeType}` : ""; const tlsFileBlock = context.config.tlsMode === "file" || context.config.tlsMode === "self_signed_dev" ? `tls:\n cert: ${context.config.tlsCertPath}\n key: ${context.config.tlsKeyPath}` diff --git a/orchestrator/src/steps/preflight.ts b/orchestrator/src/steps/preflight.ts index cd503a7..c5a638a 100644 --- a/orchestrator/src/steps/preflight.ts +++ b/orchestrator/src/steps/preflight.ts @@ -12,6 +12,15 @@ async function isPortBusy(port: number): Promise { } } +async function isUnitActive(unit: string): Promise { + try { + await run`systemctl is-active --quiet ${unit}`; + return true; + } catch { + return false; + } +} + export async function preflight(context: InstallContext): Promise { const isReconfigure = context.packageVersion === "reconfigure"; @@ -76,11 +85,24 @@ export async function preflight(context: InstallContext): Promise { } } - if (await isPortBusy(context.config.hysteriaPort)) { - fail(`Hysteria UDP/TCP port already appears to be in use: ${context.config.hysteriaPort}`); + const hysteriaPortBusy = await isPortBusy(context.config.hysteriaPort); + const uiPortBusy = await isPortBusy(context.config.uiPort); + + if (!isReconfigure) { + if (hysteriaPortBusy) { + fail(`Hysteria UDP/TCP port already appears to be in use: ${context.config.hysteriaPort}`); + } + if (uiPortBusy) { + fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`); + } + return; } - if (await isPortBusy(context.config.uiPort)) { - fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`); + if (hysteriaPortBusy && !(await isUnitActive("hysteria-server"))) { + fail(`Hysteria port ${context.config.hysteriaPort} is occupied by a non-HY2XS process`); + } + + if (uiPortBusy && !(await isUnitActive("hy2xs-admin"))) { + fail(`HY2XS admin port ${context.config.uiPort} is occupied by a non-HY2XS process`); } } diff --git a/orchestrator/src/steps/smoke.ts b/orchestrator/src/steps/smoke.ts index d6bdf62..72afe83 100644 --- a/orchestrator/src/steps/smoke.ts +++ b/orchestrator/src/steps/smoke.ts @@ -16,6 +16,7 @@ export async function smoke(context: InstallContext): Promise { await runVisible`test -s /etc/hy2xs/hy2xs.env`; await runVisible`test -s /etc/hysteria/post-install.env`; await runVisible`test -s ${context.config.bootstrapAdminSecretPath}`; + await runVisible`grep -q '^${context.config.adminUser}:' ${context.config.bootstrapAdminSecretPath}`; await runVisible`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '600'`; await runVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`; await runVisible`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`; @@ -26,10 +27,18 @@ export async function smoke(context: InstallContext): Promise { } await runVisible`ss -H -lun | grep -q '0.0.0.0:${context.config.hysteriaPort} '`; await runVisible`! ss -H -ltnu | grep -q '\[::\]'`; - await runHidden`curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth >/dev/null`; + const invalidAuthResponse = await runSecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth`; + if (!/"ok"\s*:\s*false/.test(invalidAuthResponse)) { + throw new Error(`unexpected auth response for invalid credentials: ${invalidAuthResponse}`); + } + + const validAuthResponse = await runSecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${context.config.adminUser}.${context.config.adminInitialPassword}","tx":"0"}' http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth`; + if (!/"ok"\s*:\s*true/.test(validAuthResponse)) { + throw new Error(`unexpected auth response for valid credentials`); + } await runHidden`curl -fsS --max-time 5 -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online >/dev/null`; - const deniedCode = await runSecret`curl -fsS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`; + const deniedCode = await runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`; if (!/(401|403)/.test(deniedCode)) { throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`); } diff --git a/orchestrator/src/types/context.ts b/orchestrator/src/types/context.ts index 9ec756d..6a36bc6 100644 --- a/orchestrator/src/types/context.ts +++ b/orchestrator/src/types/context.ts @@ -1,6 +1,7 @@ export type InstallOptions = { packageDir: string; - configPath: string; + sourceConfigPath: string; + runtimeConfigPath: string; nonInteractive: boolean; skipFirewall: boolean; skipStart: boolean; @@ -8,7 +9,8 @@ export type InstallOptions = { export type ReconfigureOptions = { packageDir: string; - configPath: string; + sourceConfigPath: string; + runtimeConfigPath: string; nonInteractive: boolean; dryRun: boolean; apply: boolean; diff --git a/package/config/hy2xs.env b/package/config/hy2xs.env index 4ec7461..6a596f8 100644 --- a/package/config/hy2xs.env +++ b/package/config/hy2xs.env @@ -1,7 +1,7 @@ # HY2XS canonical production runtime config (packaged baseline) HY2XS_IPV6_ENABLED=false -HY2XS_DOMAIN= -HY2XS_PUBLIC_HOST=127.0.0.1 +HY2XS_DOMAIN=replace-with-your-domain.example +HY2XS_PUBLIC_HOST=replace-with-your-domain.example HY2XS_PUBLIC_PORT=443 HY2XS_SSH_PORT=22 HY2XS_FIREWALL_ENABLED=true @@ -9,10 +9,11 @@ HY2XS_FIREWALL_STAGED_APPLY=true HY2XS_UI_BIND_HOST=127.0.0.1 HY2XS_UI_PORT=8080 HY2XS_ADMIN_USER=admin -HY2XS_ADMIN_INITIAL_PASSWORD= +HY2XS_ADMIN_INITIAL_PASSWORD=__GENERATE__ HY2XS_FORCE_PASSWORD_CHANGE=true -HY2XS_TLS_MODE=self_signed_dev -HY2XS_ACME_EMAIL= +HY2XS_TLS_MODE=acme +HY2XS_ACME_TYPE=http +HY2XS_ACME_EMAIL=replace-with-your-email@example.com HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt HY2XS_TLS_KEY_PATH=/etc/hysteria/server.key HY2XS_HYSTERIA_BIND_HOST=0.0.0.0 @@ -21,9 +22,9 @@ HY2XS_HYSTERIA_AUTH_MODE=http HY2XS_HYSTERIA_AUTH_URL=http://127.0.0.1:8080/hui/hysteria2/auth HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1 HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712 -HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET= +HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=__GENERATE__ HY2XS_HYSTERIA_OBFS_TYPE=salamander -HY2XS_HYSTERIA_OBFS_PASSWORD= +HY2XS_HYSTERIA_OBFS_PASSWORD=__GENERATE__ HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=false diff --git a/package/systemd/hy2xs-admin.service b/package/systemd/hy2xs-admin.service index 99aebf6..0bf2490 100644 --- a/package/systemd/hy2xs-admin.service +++ b/package/systemd/hy2xs-admin.service @@ -8,9 +8,9 @@ Type=simple User=hy2xs-admin Group=hy2xs-admin WorkingDirectory={{INSTALL_DIR}} +EnvironmentFile=/etc/hy2xs/hy2xs.env Environment=HUI_DATA={{DATA_DIR}}/ -Environment=HY2XS_UI_BIND_HOST={{UI_BIND_HOST}} -Environment=HY2XS_UI_PORT={{UI_PORT}} +Environment=HUI_LOG={{LOG_DIR}} ExecStart={{INSTALL_DIR}}/hy2xs-admin -p {{UI_PORT}} Restart=on-failure RestartSec=5s