fix(fix3): runtime env hardening and public endpoint source-of-truth

This commit is contained in:
2026-04-28 04:45:43 +05:00
parent 96d9bbcece
commit 12c65c8e31
23 changed files with 131 additions and 238 deletions
+4 -148
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
@@ -15,8 +14,6 @@ import (
"hy2xs-admin/service" "hy2xs-admin/service"
"hy2xs-admin/util" "hy2xs-admin/util"
"io" "io"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -167,24 +164,7 @@ func GetHysteria2Config(c *gin.Context) {
} }
func UpdateHysteria2Config(c *gin.Context) { func UpdateHysteria2Config(c *gin.Context) {
hysteria2ServerConfig, err := validateField(c, bo.Hysteria2ServerConfig{}) vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
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)
} }
func ExportHysteria2Config(c *gin.Context) { func ExportHysteria2Config(c *gin.Context) {
@@ -253,84 +233,7 @@ func ExportHysteria2Config(c *gin.Context) {
} }
func ImportHysteria2Config(c *gin.Context) { func ImportHysteria2Config(c *gin.Context) {
file, header, err := c.Request.FormFile("file") vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
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)
} }
func ExportConfig(c *gin.Context) { func ExportConfig(c *gin.Context) {
@@ -401,56 +304,9 @@ func Hysteria2AcmePath(c *gin.Context) {
} }
func RestartServer(c *gin.Context) { func RestartServer(c *gin.Context) {
go func() { vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
_ = service.StopServer()
}()
vo.Success(nil, c)
} }
func UploadCertFile(c *gin.Context) { func UploadCertFile(c *gin.Context) {
file, err := c.FormFile("file") vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
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)
} }
+3 -10
View File
@@ -64,7 +64,7 @@ func Hysteria2Url(c *gin.Context) {
return return
} }
url, err := service.Hysteria2Url(*hysteria2UrlDto.AccountId, *hysteria2UrlDto.Hostname) url, err := service.Hysteria2Url(*hysteria2UrlDto.AccountId)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
@@ -88,8 +88,7 @@ func Hysteria2SubscribeUrl(c *gin.Context) {
return return
} }
subscribeUrl, err := service.Hysteria2SubscribeUrl(*hysteria2SubscribeUrlDto.AccountId, subscribeUrl, err := service.Hysteria2SubscribeUrl(*hysteria2SubscribeUrlDto.AccountId,
*hysteria2SubscribeUrlDto.Protocol, *hysteria2SubscribeUrlDto.Protocol)
*hysteria2SubscribeUrlDto.Host)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
@@ -114,12 +113,6 @@ func Hysteria2Subscribe(c *gin.Context) {
return return
} }
userAgent := strings.ToLower(c.Request.Header.Get("User-Agent")) 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 var clientType string
if strings.Contains(userAgent, constant.Shadowrocket) { if strings.Contains(userAgent, constant.Shadowrocket) {
@@ -134,7 +127,7 @@ func Hysteria2Subscribe(c *gin.Context) {
clientType = constant.Clash clientType = constant.Clash
} }
userInfo, configStr, err := service.Hysteria2Subscribe(conPass, clientType, host) userInfo, configStr, err := service.Hysteria2Subscribe(conPass, clientType)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
-2
View File
@@ -6,12 +6,10 @@ export interface Hysteria2KickDto {
export interface Hysteria2SubscribeUrlDto { export interface Hysteria2SubscribeUrlDto {
accountId: number; accountId: number;
protocol: string; protocol: string;
host: string;
} }
export interface Hysteria2UrlDto { export interface Hysteria2UrlDto {
accountId: number; accountId: number;
hostname: string;
} }
export interface Hysteria2SubscribeVo { export interface Hysteria2SubscribeVo {
-2
View File
@@ -9,8 +9,6 @@ declare module "*.vue" {
// TypeScript-подсказки для переменных окружения // TypeScript-подсказки для переменных окружения
interface ImportMetaEnv { interface ImportMetaEnv {
VITE_APP_PORT: string;
VITE_APP_BASE_API: string;
} }
interface ImportMeta { interface ImportMeta {
+2 -1
View File
@@ -2,9 +2,10 @@ import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios";
import { useAccountStoreHook } from "@/store/modules/account"; import { useAccountStoreHook } from "@/store/modules/account";
const dynamicBase = (window as any).__dynamic_base__ || ""; const dynamicBase = (window as any).__dynamic_base__ || "";
const API_BASE = "/hui";
// Создание axios instance // Создание axios instance
const service = axios.create({ const service = axios.create({
baseURL: `${dynamicBase}${import.meta.env.VITE_APP_BASE_API}`, baseURL: `${dynamicBase}${API_BASE}`,
timeout: 50000, timeout: 50000,
headers: { "Content-Type": "application/json;charset=utf-8" }, headers: { "Content-Type": "application/json;charset=utf-8" },
}); });
@@ -906,7 +906,6 @@ const handleSubscribe = async (row: { [key: string]: any }) => {
const dto: Hysteria2SubscribeUrlDto = { const dto: Hysteria2SubscribeUrlDto = {
accountId: row.id, accountId: row.id,
protocol: window.location.protocol, protocol: window.location.protocol,
host: window.location.host,
}; };
const { data } = await hysteria2SubscribeUrlApi(dto); const { data } = await hysteria2SubscribeUrlApi(dto);
copy(data.url); copy(data.url);
@@ -920,7 +919,6 @@ const handleNodeUrl = async (row: { [key: string]: any }) => {
try { try {
const dto: Hysteria2UrlDto = { const dto: Hysteria2UrlDto = {
accountId: row.id, accountId: row.id,
hostname: window.location.hostname,
}; };
const { data } = await hysteria2UrlApi(dto); const { data } = await hysteria2UrlApi(dto);
copy(data.url); copy(data.url);
@@ -934,7 +932,6 @@ const handleQrCode = async (row: { [key: string]: any }) => {
try { try {
const dto: Hysteria2UrlDto = { const dto: Hysteria2UrlDto = {
accountId: row.id, accountId: row.id,
hostname: window.location.hostname,
}; };
const { data } = await hysteria2UrlApi(dto); const { data } = await hysteria2UrlApi(dto);
state.qrCodeSrc = "data:image/png;base64," + data.qrCode; state.qrCodeSrc = "data:image/png;base64," + data.qrCode;
@@ -204,7 +204,6 @@ const handleSubscribe = async () => {
const dto: Hysteria2SubscribeUrlDto = { const dto: Hysteria2SubscribeUrlDto = {
accountId: accountStore.id, accountId: accountStore.id,
protocol: window.location.protocol, protocol: window.location.protocol,
host: window.location.host,
}; };
const { data } = await hysteria2SubscribeUrlApi(dto); const { data } = await hysteria2SubscribeUrlApi(dto);
copy(data.url); copy(data.url);
@@ -219,7 +218,6 @@ const handleSubscribeQrCode = async () => {
const dto: Hysteria2SubscribeUrlDto = { const dto: Hysteria2SubscribeUrlDto = {
accountId: accountStore.id, accountId: accountStore.id,
protocol: window.location.protocol, protocol: window.location.protocol,
host: window.location.host,
}; };
const { data } = await hysteria2SubscribeUrlApi(dto); const { data } = await hysteria2SubscribeUrlApi(dto);
state.qrCodeSrc = "data:image/png;base64," + data.qrCode; state.qrCodeSrc = "data:image/png;base64," + data.qrCode;
@@ -233,7 +231,6 @@ const handleNodeUrl = async () => {
try { try {
const dto: Hysteria2UrlDto = { const dto: Hysteria2UrlDto = {
accountId: accountStore.id, accountId: accountStore.id,
hostname: window.location.hostname,
}; };
const { data } = await hysteria2UrlApi(dto); const { data } = await hysteria2UrlApi(dto);
copy(data.url); copy(data.url);
@@ -247,7 +244,6 @@ const handleUrlQrCode = async () => {
try { try {
const dto: Hysteria2UrlDto = { const dto: Hysteria2UrlDto = {
accountId: accountStore.id, accountId: accountStore.id,
hostname: window.location.hostname,
}; };
const { data } = await hysteria2UrlApi(dto); const { data } = await hysteria2UrlApi(dto);
state.qrCodeSrc = "data:image/png;base64," + data.qrCode; state.qrCodeSrc = "data:image/png;base64," + data.qrCode;
+6 -5
View File
@@ -1,6 +1,6 @@
import vue from "@vitejs/plugin-vue"; 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 AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/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 // eslint-disable-next-line no-control-regex
const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g; const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g;
const DRIVE_LETTER_REGEX = /^[a-z]:/i; const DRIVE_LETTER_REGEX = /^[a-z]:/i;
const DEV_SERVER_PORT = 8080;
const API_BASE = "/hui";
export default defineConfig(({ mode }: ConfigEnv): UserConfig => { export default defineConfig((): UserConfig => {
const env = loadEnv(mode, process.cwd());
return { return {
base: "/", base: "/",
resolve: { resolve: {
@@ -44,11 +45,11 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
}, },
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: Number(env.VITE_APP_PORT), port: DEV_SERVER_PORT,
open: true, // Automatically open browser on start open: true, // Automatically open browser on start
proxy: { proxy: {
// Reverse proxy for CORS in development // Reverse proxy for CORS in development
[env.VITE_APP_BASE_API]: { [API_BASE]: {
target: "http://127.0.0.1:8081", target: "http://127.0.0.1:8081",
changeOrigin: true, changeOrigin: true,
}, },
-2
View File
@@ -18,10 +18,8 @@ type Hysteria2VersionDto struct {
type Hysteria2SubscribeUrlDto struct { type Hysteria2SubscribeUrlDto struct {
AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"` AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"`
Protocol *string `json:"protocol" form:"protocol" validate:"required,min=1,max=8"` 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 { type Hysteria2UrlDto struct {
AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"` AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"`
Hostname *string `json:"hostname" form:"hostname" validate:"required,min=1,max=255"`
} }
+27 -26
View File
@@ -8,26 +8,27 @@ import (
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/proxy" "hy2xs-admin/proxy"
"net"
"net/url" "net/url"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
) )
func parseListenPort(listen string) (int, error) { func resolvePublicEndpoint() (string, int, error) {
host, port, err := net.SplitHostPort(listen) host := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_HOST"))
if err != nil { if host == "" || host == "0.0.0.0" {
return 0, err return "", 0, errors.New("HY2XS_PUBLIC_HOST must be set to public domain or IPv4")
} }
if host == "" || port == "" { portRaw := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_PORT"))
return 0, errors.New("invalid listen address") if portRaw == "" {
return "", 0, errors.New("HY2XS_PUBLIC_PORT is required")
} }
value, convErr := strconv.Atoi(port) port, err := strconv.Atoi(portRaw)
if convErr != nil || value < 1 || value > 65535 { if err != nil || port < 1 || port > 65535 {
return 0, errors.New("invalid listen port") 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) { func Hysteria2Auth(conPass string) (int64, string, error) {
@@ -103,11 +104,15 @@ func Hysteria2Kick(ids []int64, kickUtilTime int64) error {
return nil 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) account, err := dao.GetAccount("id = ?", accountId)
if err != nil { if err != nil {
return "", err return "", err
} }
publicHost, publicPort, err := resolvePublicEndpoint()
if err != nil {
return "", err
}
config, err := dao.GetConfig("key = ?", constant.HUIWebContext) config, err := dao.GetConfig("key = ?", constant.HUIWebContext)
if err != nil { if err != nil {
return "", err 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, "/") { if config.Value != nil && *config.Value != "/" && strings.HasPrefix(*config.Value, "/") {
webContext = *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() hysteria2Config, err := GetHysteria2Config()
if err != nil { if err != nil {
return "", "", err return "", "", err
@@ -132,6 +137,10 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string,
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
publicHost, publicPort, err := resolvePublicEndpoint()
if err != nil {
return "", "", err
}
hysteria2Name := "hysteria2" hysteria2Name := "hysteria2"
hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark) hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark)
@@ -144,11 +153,6 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string,
userInfo := "" userInfo := ""
configStr := "" configStr := ""
listenPort, err := parseListenPort(*hysteria2Config.Listen)
if err != nil {
return "", "", err
}
publicHost := strings.Split(host, ":")[0]
if clientType == constant.Shadowrocket || clientType == constant.Clash { if clientType == constant.Shadowrocket || clientType == constant.Clash {
userInfo = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", userInfo = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d",
*account.Upload, *account.Upload,
@@ -160,7 +164,7 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string,
Name: hysteria2Name, Name: hysteria2Name,
Type: "hysteria2", Type: "hysteria2",
Server: publicHost, Server: publicHost,
Port: strconv.Itoa(listenPort), Port: strconv.Itoa(publicPort),
Password: conPass, Password: conPass,
} }
@@ -224,7 +228,7 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string,
} }
} }
} else if clientType == constant.V2rayN { } else if clientType == constant.V2rayN {
hysteria2Url, err := Hysteria2Url(*account.Id, strings.Split(host, ":")[0]) hysteria2Url, err := Hysteria2Url(*account.Id)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -234,7 +238,7 @@ func Hysteria2Subscribe(conPass string, clientType string, host string) (string,
return userInfo, configStr, nil return userInfo, configStr, nil
} }
func Hysteria2Url(accountId int64, hostname string) (string, error) { func Hysteria2Url(accountId int64) (string, error) {
hysteria2Config, err := GetHysteria2Config() hysteria2Config, err := GetHysteria2Config()
if err != nil { if err != nil {
return "", err return "", err
@@ -242,13 +246,10 @@ func Hysteria2Url(accountId int64, hostname string) (string, error) {
if hysteria2Config.Listen == nil || *hysteria2Config.Listen == "" { if hysteria2Config.Listen == nil || *hysteria2Config.Listen == "" {
return "", errors.New("hysteria2 config is empty") return "", errors.New("hysteria2 config is empty")
} }
port, err := parseListenPort(*hysteria2Config.Listen) hostname, port, err := resolvePublicEndpoint()
if err != nil { if err != nil {
return "", err return "", err
} }
if hostname == "" || hostname == "0.0.0.0" {
return "", errors.New("invalid public host")
}
account, err := dao.GetAccount("id = ?", accountId) account, err := dao.GetAccount("id = ?", accountId)
if err != nil { if err != nil {
+2
View File
@@ -68,6 +68,7 @@ Hysteria2 — основной транспортный компонент се
Инварианты: Инварианты:
- `acme` -> только `acme` block в конфиге; - `acme` -> только `acme` block в конфиге;
- `acme` block обязан содержать `type: http|tls|dns` из runtime env (`HY2XS_ACME_TYPE`);
- `file` -> только `tls.cert`/`tls.key` block; - `file` -> только `tls.cert`/`tls.key` block;
- `self_signed_dev` -> только dev сценарии. - `self_signed_dev` -> только dev сценарии.
@@ -111,3 +112,4 @@ Hysteria2 — основной транспортный компонент се
7. bundled UI работает поверх актуального состояния сервера 7. bundled UI работает поверх актуального состояния сервера
8. `trafficStats.secret` отдельный от `JWT_SECRET` 8. `trafficStats.secret` отдельный от `JWT_SECRET`
9. IPv6 listen не используется 9. IPv6 listen не используется
10. публичные клиентские endpoint/URL берутся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen`/request-host
+4
View File
@@ -35,6 +35,10 @@
В клиентской части используется только `public_host/public_port`. В клиентской части используется только `public_host/public_port`.
В runtime-обозначениях HY2XS это эквивалентно:
- `HY2XS_PUBLIC_HOST`
- `HY2XS_PUBLIC_PORT`
Инварианты: Инварианты:
- `listen` и `public endpoint` разделены; - `listen` и `public endpoint` разделены;
- в клиентских URL не используется `0.0.0.0`; - в клиентских URL не используется `0.0.0.0`;
+3
View File
@@ -47,6 +47,8 @@
12. bootstrap admin secret существует и имеет `0600` 12. bootstrap admin secret существует и имеет `0600`
13. `trafficStats` API: корректный secret принимает запрос, неверный secret отклоняется 13. `trafficStats` API: корректный secret принимает запрос, неверный secret отклоняется
14. TLS mode в `config.yaml` соответствует runtime env (`acme|file|self_signed_dev`) 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 15. `nft -c -f /etc/nftables.conf` проходит после apply
## D. Negative tests ## D. Negative tests
@@ -76,3 +78,4 @@
8. Telegram/access layer не требуется для прохождения install acceptance 8. Telegram/access layer не требуется для прохождения install acceptance
9. отсутствует production path для port hopping 9. отсутствует production path для port hopping
10. UI не запускается от root 10. UI не запускается от root
11. клиентские endpoint не зависят от request `Host`/`hostname`
+6 -4
View File
@@ -21,7 +21,8 @@ function takeValue(args: string[], index: number, flag: string): string {
function parseInstallOptions(args: string[]): InstallOptions { function parseInstallOptions(args: string[]): InstallOptions {
const options: InstallOptions = { const options: InstallOptions = {
packageDir: "", packageDir: "",
configPath: "/etc/hy2xs/hy2xs.env", sourceConfigPath: "",
runtimeConfigPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: false, nonInteractive: false,
skipFirewall: false, skipFirewall: false,
skipStart: false skipStart: false
@@ -44,7 +45,7 @@ function parseInstallOptions(args: string[]): InstallOptions {
options.skipStart = true; options.skipStart = true;
break; break;
case "--config": case "--config":
options.configPath = takeValue(args, i, arg); options.sourceConfigPath = takeValue(args, i, arg);
i += 1; i += 1;
break; break;
default: default:
@@ -64,7 +65,8 @@ function parseInstallOptions(args: string[]): InstallOptions {
function parseReconfigureOptions(args: string[]): ReconfigureOptions { function parseReconfigureOptions(args: string[]): ReconfigureOptions {
const options: ReconfigureOptions = { const options: ReconfigureOptions = {
packageDir: "", packageDir: "",
configPath: "/etc/hy2xs/hy2xs.env", sourceConfigPath: "/etc/hy2xs/hy2xs.env",
runtimeConfigPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: false, nonInteractive: false,
dryRun: false, dryRun: false,
apply: false, apply: false,
@@ -80,7 +82,7 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions {
i += 1; i += 1;
break; break;
case "--config": case "--config":
options.configPath = takeValue(args, i, arg); options.sourceConfigPath = takeValue(args, i, arg);
i += 1; i += 1;
break; break;
case "--dry-run": case "--dry-run":
+8 -7
View File
@@ -28,8 +28,11 @@ function secret(): string {
} }
export async function install(options: InstallOptions): Promise<void> { export async function install(options: InstallOptions): Promise<void> {
const hasConfig = await exists(options.configPath); const hasSourceConfig = options.sourceConfigPath ? await exists(options.sourceConfigPath) : false;
const sourceConfigPath = hasConfig ? options.configPath : `${options.packageDir}/config/hy2xs.env`; 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 sourceConfigRaw = await readText(sourceConfigPath);
const config = parseRuntimeEnv(sourceConfigRaw); const config = parseRuntimeEnv(sourceConfigRaw);
@@ -49,11 +52,9 @@ export async function install(options: InstallOptions): Promise<void> {
await installDeps(context); await installDeps(context);
step("filesystem"); step("filesystem");
await prepareFilesystem(context); await prepareFilesystem(context);
if (!hasConfig) { step("write runtime env");
step("write runtime env"); await runVisible`mkdir -p /etc/hy2xs`;
await runVisible`mkdir -p /etc/hy2xs`; await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600);
await writeText(options.configPath, renderRuntimeEnv(config), 0o600);
}
step("bundled UI"); step("bundled UI");
await deployUi(context); await deployUi(context);
step("Hysteria2 upstream install"); step("Hysteria2 upstream install");
+4 -3
View File
@@ -28,7 +28,7 @@ async function rollbackCurrentState(): Promise<void> {
} }
export async function reconfigure(options: ReconfigureOptions): Promise<void> { export async function reconfigure(options: ReconfigureOptions): Promise<void> {
const configRaw = await readText(options.configPath); const configRaw = await readText(options.sourceConfigPath);
const config = parseRuntimeEnv(configRaw); const config = parseRuntimeEnv(configRaw);
const context: ReconfigureContext & { packageVersion: string; packageBuildId: string; installDate: string; hysteriaAuthPassword: string; hysteriaVersion: string } = { const context: ReconfigureContext & { packageVersion: string; packageBuildId: string; installDate: string; hysteriaAuthPassword: string; hysteriaVersion: string } = {
@@ -46,7 +46,8 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
if (options.dryRun) { if (options.dryRun) {
info("reconfigure dry-run: validated config and execution graph"); 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(`ui bind: ${config.uiBindHost}:${config.uiPort}`);
info(`hysteria bind: ${config.hysteriaBindHost}:${config.hysteriaPort}`); info(`hysteria bind: ${config.hysteriaBindHost}:${config.hysteriaPort}`);
info(`public endpoint: ${config.publicHost}:${config.publicPort}`); info(`public endpoint: ${config.publicHost}:${config.publicPort}`);
@@ -64,7 +65,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
step("firewall"); step("firewall");
await applyFirewall(context); await applyFirewall(context);
step("write env artifacts"); step("write env artifacts");
await writeText(options.configPath, renderRuntimeEnv(config), 0o600); await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600);
await writePostInstallEnv(context); await writePostInstallEnv(context);
step("smoke checks"); step("smoke checks");
await smoke(context); await smoke(context);
+10 -3
View File
@@ -7,6 +7,13 @@ function randomSecret(): string {
return randomBytes(24).toString("base64url"); return randomBytes(24).toString("base64url");
} }
function valueOrGenerate(value: string | undefined): string {
if (!value || value === "__GENERATE__") {
return randomSecret();
}
return value;
}
function parseEnv(content: string): EnvMap { function parseEnv(content: string): EnvMap {
const result: EnvMap = {}; const result: EnvMap = {};
for (const rawLine of content.split(/\r?\n/)) { for (const rawLine of content.split(/\r?\n/)) {
@@ -111,7 +118,7 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
uiBindHost, uiBindHost,
uiPort, uiPort,
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"), 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), forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, true),
tlsMode, tlsMode,
acmeType, acmeType,
@@ -127,9 +134,9 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
env.HY2XS_HYSTERIA_TRAFFIC_STATS_HOST || "127.0.0.1" env.HY2XS_HYSTERIA_TRAFFIC_STATS_HOST || "127.0.0.1"
), ),
hysteriaTrafficStatsPort: trafficStatsPort, hysteriaTrafficStatsPort: trafficStatsPort,
hysteriaTrafficStatsSecret: env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET || randomSecret(), hysteriaTrafficStatsSecret: valueOrGenerate(env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET),
hysteriaObfsType: "salamander", 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", hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps", hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
hysteriaIgnoreClientBandwidth: parseBool( hysteriaIgnoreClientBandwidth: parseBool(
+1 -1
View File
@@ -4,7 +4,7 @@ import { runVisible } from "../lib/process";
export async function generateConfig(context: InstallContext): Promise<void> { export async function generateConfig(context: InstallContext): Promise<void> {
const tlsAcmeBlock = context.config.tlsMode === "acme" 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" const tlsFileBlock = context.config.tlsMode === "file" || context.config.tlsMode === "self_signed_dev"
? `tls:\n cert: ${context.config.tlsCertPath}\n key: ${context.config.tlsKeyPath}` ? `tls:\n cert: ${context.config.tlsCertPath}\n key: ${context.config.tlsKeyPath}`
+26 -4
View File
@@ -12,6 +12,15 @@ async function isPortBusy(port: number): Promise<boolean> {
} }
} }
async function isUnitActive(unit: string): Promise<boolean> {
try {
await run`systemctl is-active --quiet ${unit}`;
return true;
} catch {
return false;
}
}
export async function preflight(context: InstallContext): Promise<void> { export async function preflight(context: InstallContext): Promise<void> {
const isReconfigure = context.packageVersion === "reconfigure"; const isReconfigure = context.packageVersion === "reconfigure";
@@ -76,11 +85,24 @@ export async function preflight(context: InstallContext): Promise<void> {
} }
} }
if (await isPortBusy(context.config.hysteriaPort)) { const hysteriaPortBusy = await isPortBusy(context.config.hysteriaPort);
fail(`Hysteria UDP/TCP port already appears to be in use: ${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)) { if (hysteriaPortBusy && !(await isUnitActive("hysteria-server"))) {
fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`); 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`);
} }
} }
+11 -2
View File
@@ -16,6 +16,7 @@ export async function smoke(context: InstallContext): Promise<void> {
await runVisible`test -s /etc/hy2xs/hy2xs.env`; await runVisible`test -s /etc/hy2xs/hy2xs.env`;
await runVisible`test -s /etc/hysteria/post-install.env`; await runVisible`test -s /etc/hysteria/post-install.env`;
await runVisible`test -s ${context.config.bootstrapAdminSecretPath}`; 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/hysteria/config.yaml)" = '600'`;
await runVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`; await runVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`;
await runVisible`test "$(stat -c '%a' /etc/hysteria/post-install.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<void> {
} }
await runVisible`ss -H -lun | grep -q '0.0.0.0:${context.config.hysteriaPort} '`; await runVisible`ss -H -lun | grep -q '0.0.0.0:${context.config.hysteriaPort} '`;
await runVisible`! ss -H -ltnu | grep -q '\[::\]'`; 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`; 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)) { if (!/(401|403)/.test(deniedCode)) {
throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`); throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`);
} }
+4 -2
View File
@@ -1,6 +1,7 @@
export type InstallOptions = { export type InstallOptions = {
packageDir: string; packageDir: string;
configPath: string; sourceConfigPath: string;
runtimeConfigPath: string;
nonInteractive: boolean; nonInteractive: boolean;
skipFirewall: boolean; skipFirewall: boolean;
skipStart: boolean; skipStart: boolean;
@@ -8,7 +9,8 @@ export type InstallOptions = {
export type ReconfigureOptions = { export type ReconfigureOptions = {
packageDir: string; packageDir: string;
configPath: string; sourceConfigPath: string;
runtimeConfigPath: string;
nonInteractive: boolean; nonInteractive: boolean;
dryRun: boolean; dryRun: boolean;
apply: boolean; apply: boolean;
+8 -7
View File
@@ -1,7 +1,7 @@
# HY2XS canonical production runtime config (packaged baseline) # HY2XS canonical production runtime config (packaged baseline)
HY2XS_IPV6_ENABLED=false HY2XS_IPV6_ENABLED=false
HY2XS_DOMAIN= HY2XS_DOMAIN=replace-with-your-domain.example
HY2XS_PUBLIC_HOST=127.0.0.1 HY2XS_PUBLIC_HOST=replace-with-your-domain.example
HY2XS_PUBLIC_PORT=443 HY2XS_PUBLIC_PORT=443
HY2XS_SSH_PORT=22 HY2XS_SSH_PORT=22
HY2XS_FIREWALL_ENABLED=true HY2XS_FIREWALL_ENABLED=true
@@ -9,10 +9,11 @@ HY2XS_FIREWALL_STAGED_APPLY=true
HY2XS_UI_BIND_HOST=127.0.0.1 HY2XS_UI_BIND_HOST=127.0.0.1
HY2XS_UI_PORT=8080 HY2XS_UI_PORT=8080
HY2XS_ADMIN_USER=admin HY2XS_ADMIN_USER=admin
HY2XS_ADMIN_INITIAL_PASSWORD= HY2XS_ADMIN_INITIAL_PASSWORD=__GENERATE__
HY2XS_FORCE_PASSWORD_CHANGE=true HY2XS_FORCE_PASSWORD_CHANGE=true
HY2XS_TLS_MODE=self_signed_dev HY2XS_TLS_MODE=acme
HY2XS_ACME_EMAIL= HY2XS_ACME_TYPE=http
HY2XS_ACME_EMAIL=replace-with-your-email@example.com
HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt
HY2XS_TLS_KEY_PATH=/etc/hysteria/server.key HY2XS_TLS_KEY_PATH=/etc/hysteria/server.key
HY2XS_HYSTERIA_BIND_HOST=0.0.0.0 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_AUTH_URL=http://127.0.0.1:8080/hui/hysteria2/auth
HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1 HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1
HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712 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_TYPE=salamander
HY2XS_HYSTERIA_OBFS_PASSWORD= HY2XS_HYSTERIA_OBFS_PASSWORD=__GENERATE__
HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps
HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps
HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=false HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=false
+2 -2
View File
@@ -8,9 +8,9 @@ Type=simple
User=hy2xs-admin User=hy2xs-admin
Group=hy2xs-admin Group=hy2xs-admin
WorkingDirectory={{INSTALL_DIR}} WorkingDirectory={{INSTALL_DIR}}
EnvironmentFile=/etc/hy2xs/hy2xs.env
Environment=HUI_DATA={{DATA_DIR}}/ Environment=HUI_DATA={{DATA_DIR}}/
Environment=HY2XS_UI_BIND_HOST={{UI_BIND_HOST}} Environment=HUI_LOG={{LOG_DIR}}
Environment=HY2XS_UI_PORT={{UI_PORT}}
ExecStart={{INSTALL_DIR}}/hy2xs-admin -p {{UI_PORT}} ExecStart={{INSTALL_DIR}}/hy2xs-admin -p {{UI_PORT}}
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s