fix(fix4): enforce runtime contract and orchestrator guardrails

This commit is contained in:
2026-04-28 05:54:07 +05:00
parent 12c65c8e31
commit 7734a76c39
15 changed files with 92 additions and 79 deletions
+9 -1
View File
@@ -57,9 +57,17 @@ https://git.ext.flamy.studio/flamy_dev/HY2XS_flamy.git
На чистом Debian 12 target нужно распаковать архив и запустить от root: На чистом Debian 12 target нужно распаковать архив и запустить от root:
```sh ```sh
./install.sh --config /etc/hy2xs/hy2xs.env --non-interactive ./install.sh --non-interactive
``` ```
Опционально можно передать внешний source-config (не путь runtime-файла на target):
```sh
./install.sh --config /root/custom-hy2xs.env --non-interactive
```
`/etc/hy2xs/hy2xs.env` создаётся install-слоем автоматически и далее редактируется оператором перед `reconfigure`.
После установки применяются команды оркестратора: После установки применяются команды оркестратора:
```sh ```sh
+30 -9
View File
@@ -19,6 +19,21 @@ import (
"time" "time"
) )
func isOrchestratorManagedConfigKey(key string) bool {
switch key {
case constant.HUIWebPort,
constant.HUIWebContext,
constant.HUICrtPath,
constant.HUIKeyPath,
constant.Hysteria2Enable,
constant.Hysteria2Config,
constant.Hysteria2TrafficStatsSecret:
return true
default:
return false
}
}
func UpdateConfigs(c *gin.Context) { func UpdateConfigs(c *gin.Context) {
configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{}) configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{})
if err != nil { if err != nil {
@@ -37,6 +52,11 @@ func UpdateConfigs(c *gin.Context) {
key := *item.Key key := *item.Key
value := *item.Value value := *item.Value
if isOrchestratorManagedConfigKey(key) {
vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", key), c)
return
}
if key == constant.HUIWebPort && strconv.FormatInt(port, 10) != value { if key == constant.HUIWebPort && strconv.FormatInt(port, 10) != value {
port, err := strconv.Atoi(value) port, err := strconv.Atoi(value)
if err != nil { if err != nil {
@@ -117,17 +137,12 @@ func GetConfig(c *gin.Context) {
} }
running := service.Hysteria2IsRunning() running := service.Hysteria2IsRunning()
if *config.Key == constant.Hysteria2Enable {
if (*config.Value == "1") != running {
enable := "0"
if running { if running {
enable = "1" configVo.Value = "1"
} else {
configVo.Value = "0"
} }
if err := service.UpdateConfig(constant.Hysteria2Enable, enable); err != nil {
vo.Fail(err.Error(), c)
return
}
configVo.Value = enable
} }
vo.Success(configVo, c) vo.Success(configVo, c)
@@ -284,6 +299,12 @@ func ImportConfig(c *gin.Context) {
vo.Fail("content Unmarshal err", c) vo.Fail("content Unmarshal err", c)
return return
} }
for _, cfg := range configs {
if cfg.Key != nil && isOrchestratorManagedConfigKey(*cfg.Key) {
vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", *cfg.Key), c)
return
}
}
if err = service.UpsertConfig(configs); err != nil { if err = service.UpsertConfig(configs); err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
+8 -16
View File
@@ -2,7 +2,6 @@ package dao
import ( import (
"errors" "errors"
"fmt"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
@@ -120,7 +119,7 @@ func ensureAccountSchema() error {
func ensureSecureBootstrapAdmin() error { func ensureSecureBootstrapAdmin() error {
adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER")) adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER"))
if adminUser == "" { if adminUser == "" {
adminUser = "admin" adminUser = "hy2xsadmin"
} }
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")) adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
if adminPassword == "" { if adminPassword == "" {
@@ -136,11 +135,14 @@ func ensureSecureBootstrapAdmin() error {
deviceNo := int64(envInt("HY2XS_ADMIN_DEVICE_NO", 6)) deviceNo := int64(envInt("HY2XS_ADMIN_DEVICE_NO", 6))
role := "admin" role := "admin"
deleted := int64(0) deleted := int64(0)
conPassSecret, conErr := util.RandomString(28) conPass := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
if conErr != nil { if conPass == "" {
return conErr generated, genErr := util.RandomString(28)
if genErr != nil {
return genErr
}
conPass = generated
} }
conPass := fmt.Sprintf("%s.%s", adminUser, conPassSecret)
hash, hashErr := util.HashPassword(adminPassword) hash, hashErr := util.HashPassword(adminPassword)
if hashErr != nil { if hashErr != nil {
return hashErr return hashErr
@@ -169,16 +171,6 @@ func ensureSecureBootstrapAdmin() error {
if admin.Pass == nil { if admin.Pass == nil {
return nil return nil
} }
updates := map[string]interface{}{
"username": adminUser,
"pass": hash,
"con_pass": conPass,
"force_password_change": forcePasswordChange,
}
if updateErr := UpdateAccount([]int64{*admin.Id}, updates); updateErr != nil {
return updateErr
}
return nil return nil
} }
-9
View File
@@ -27,9 +27,6 @@ func NewHysteria2Api(apiPort int64) *Hysteria2Api {
// ListUsers Информация о трафике каждого пользователя // ListUsers Информация о трафике каждого пользователя
func (h *Hysteria2Api) ListUsers(clear bool, secret string) (map[string]bo.Hysteria2UserTraffic, error) { func (h *Hysteria2Api) ListUsers(clear bool, secret string) (map[string]bo.Hysteria2UserTraffic, error) {
var users map[string]bo.Hysteria2UserTraffic var users map[string]bo.Hysteria2UserTraffic
if !NewHysteria2Instance().IsRunning() {
return users, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
url := fmt.Sprintf("http://127.0.0.1:%d/traffic", h.apiPort) url := fmt.Sprintf("http://127.0.0.1:%d/traffic", h.apiPort)
@@ -66,9 +63,6 @@ func (h *Hysteria2Api) ListUsers(clear bool, secret string) (map[string]bo.Hyste
// KickUsers Принудительное отключение // KickUsers Принудительное отключение
func (h *Hysteria2Api) KickUsers(keys []string, secret string) error { func (h *Hysteria2Api) KickUsers(keys []string, secret string) error {
if !NewHysteria2Instance().IsRunning() {
return nil
}
usernamesByte, err := json.Marshal(keys) usernamesByte, err := json.Marshal(keys)
if err != nil { if err != nil {
logrus.Errorf("Hysteria2 KickUsers Marshal err: %v", err) logrus.Errorf("Hysteria2 KickUsers Marshal err: %v", err)
@@ -101,9 +95,6 @@ func (h *Hysteria2Api) KickUsers(keys []string, secret string) error {
// OnlineUsers Пользователи онлайн // OnlineUsers Пользователи онлайн
func (h *Hysteria2Api) OnlineUsers(secret string) (map[string]int64, error) { func (h *Hysteria2Api) OnlineUsers(secret string) (map[string]int64, error) {
var onlineUsers map[string]int64 var onlineUsers map[string]int64
if !NewHysteria2Instance().IsRunning() {
return onlineUsers, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
url := fmt.Sprintf("http://127.0.0.1:%d/online", h.apiPort) url := fmt.Sprintf("http://127.0.0.1:%d/online", h.apiPort)
-20
View File
@@ -15,26 +15,6 @@ import (
) )
func UpdateConfig(key string, value string) error { 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}) return dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value})
} }
+14 -16
View File
@@ -17,27 +17,25 @@ var kickMutex sync.Mutex
func CronHandleAccount() { func CronHandleAccount() {
go func() { go func() {
hysteriaEnable, err := dao.GetConfig("key = ?", constant.Hysteria2Enable) if !Hysteria2IsRunning() {
return
}
apiPort, err := GetHysteria2ApiPort()
if err != nil { if err != nil {
return return
} }
if hysteriaEnable.Value != nil && *hysteriaEnable.Value == "1" {
apiPort, err := GetHysteria2ApiPort()
if err != nil {
return
}
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil { if err != nil {
return return
}
// Сохранение данных трафика
go saveAccountTraffic(apiPort, *trafficSecretConfig.Value)
// Принудительное отключение
go kickAccount(apiPort, *trafficSecretConfig.Value)
} }
// Сохранение данных трафика
go saveAccountTraffic(apiPort, *trafficSecretConfig.Value)
// Принудительное отключение
go kickAccount(apiPort, *trafficSecretConfig.Value)
}() }()
} }
-4
View File
@@ -27,10 +27,6 @@ func StartServer(crtPath string, keyPath string) error {
} }
func StopServer() error { func StopServer() error {
if err := StopHysteria2(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := server.Shutdown(ctx); err != nil { if err := server.Shutdown(ctx); err != nil {
+7
View File
@@ -63,6 +63,8 @@
- `SSH_PORT` - `SSH_PORT`
- `HY2XS_FIREWALL_ENABLED` - `HY2XS_FIREWALL_ENABLED`
- `HY2XS_FIREWALL_STAGED_APPLY` - `HY2XS_FIREWALL_STAGED_APPLY`
- `HY2XS_ADMIN_USER`
- `HY2XS_FORCE_PASSWORD_CHANGE`
### Hysteria ### Hysteria
- `HY2_SOURCE=official-upstream` - `HY2_SOURCE=official-upstream`
@@ -101,6 +103,11 @@
3. оператор запускает `reconfigure --dry-run`, затем `reconfigure --apply`; 3. оператор запускает `reconfigure --dry-run`, затем `reconfigure --apply`;
4. оркестратор обновляет runtime и перезаписывает snapshot. 4. оркестратор обновляет runtime и перезаписывает snapshot.
Важно:
- `HY2XS_ADMIN_INITIAL_PASSWORD` используется только для первичного bootstrap seed;
- `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke;
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
## Что нельзя делать ## Что нельзя делать
- сваливать туда временный мусор - сваливать туда временный мусор
+5 -1
View File
@@ -49,7 +49,10 @@
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` 15. при `HY2XS_TLS_MODE=acme` в `config.yaml` выставлен `acme.type` из `HY2XS_ACME_TYPE`
16. subscribe/node URL в API/QR формируются по `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT` 16. subscribe/node URL в API/QR формируются по `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`
15. `nft -c -f /etc/nftables.conf` проходит после apply 17. `nft -c -f /etc/nftables.conf` проходит после apply
18. пароль admin и `con_pass` не перезаписываются при рестарте `hy2xs-admin`
19. остановка/рестарт UI не останавливает `hysteria-server`
20. traffic accounting/kick ориентируются на systemd status, а не на SQLite `HYSTERIA2_ENABLE`
## D. Negative tests ## D. Negative tests
@@ -79,3 +82,4 @@
9. отсутствует production path для port hopping 9. отсутствует production path для port hopping
10. UI не запускается от root 10. UI не запускается от root
11. клиентские endpoint не зависят от request `Host`/`hostname` 11. клиентские endpoint не зависят от request `Host`/`hostname`
12. production build verify падает, если `config/hy2xs.env` содержит placeholder-значения
+2
View File
@@ -119,6 +119,7 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
uiPort, uiPort,
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"), adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD), adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD),
adminConPass: requireValue("HY2XS_ADMIN_CON_PASS", valueOrGenerate(env.HY2XS_ADMIN_CON_PASS)),
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,
@@ -202,6 +203,7 @@ export function renderRuntimeEnv(config: RuntimeConfig): string {
`HY2XS_UI_PORT=${config.uiPort}`, `HY2XS_UI_PORT=${config.uiPort}`,
`HY2XS_ADMIN_USER=${config.adminUser}`, `HY2XS_ADMIN_USER=${config.adminUser}`,
`HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`, `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`,
`HY2XS_ADMIN_CON_PASS=${config.adminConPass}`,
`HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`, `HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`,
`HY2XS_TLS_MODE=${config.tlsMode}`, `HY2XS_TLS_MODE=${config.tlsMode}`,
`HY2XS_ACME_TYPE=${config.acmeType}`, `HY2XS_ACME_TYPE=${config.acmeType}`,
+1 -1
View File
@@ -30,5 +30,5 @@ export async function writePostInstallEnv(context: InstallContext): Promise<void
}); });
await writeText("/etc/hysteria/post-install.env", rendered, 0o600); await writeText("/etc/hysteria/post-install.env", rendered, 0o600);
await writeText(context.config.bootstrapAdminSecretPath, `${context.config.adminUser}:${context.config.adminInitialPassword}\n`, 0o600); await writeText(context.config.bootstrapAdminSecretPath, `${context.config.adminUser}:${context.config.adminConPass}\n`, 0o600);
} }
+6 -1
View File
@@ -32,7 +32,12 @@ export async function smoke(context: InstallContext): Promise<void> {
throw new Error(`unexpected auth response for invalid credentials: ${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`; const adminConPass = (await runSecret`grep '^${context.config.adminUser}:' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d: -f2-`).trim();
if (!adminConPass) {
throw new Error("admin connection password is empty in bootstrap secret file");
}
const validAuthResponse = await runSecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":"0"}' http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth`;
if (!/"ok"\s*:\s*true/.test(validAuthResponse)) { if (!/"ok"\s*:\s*true/.test(validAuthResponse)) {
throw new Error(`unexpected auth response for valid credentials`); throw new Error(`unexpected auth response for valid credentials`);
} }
+1
View File
@@ -32,6 +32,7 @@ export type RuntimeConfig = {
uiPort: number; uiPort: number;
adminUser: string; adminUser: string;
adminInitialPassword: string; adminInitialPassword: string;
adminConPass: string;
forcePasswordChange: boolean; forcePasswordChange: boolean;
tlsMode: TlsMode; tlsMode: TlsMode;
acmeType: "http" | "tls" | "dns"; acmeType: "http" | "tls" | "dns";
+2 -1
View File
@@ -8,8 +8,9 @@ HY2XS_FIREWALL_ENABLED=true
HY2XS_FIREWALL_STAGED_APPLY=true 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=hy2xsadmin
HY2XS_ADMIN_INITIAL_PASSWORD=__GENERATE__ HY2XS_ADMIN_INITIAL_PASSWORD=__GENERATE__
HY2XS_ADMIN_CON_PASS=__GENERATE__
HY2XS_FORCE_PASSWORD_CHANGE=true HY2XS_FORCE_PASSWORD_CHANGE=true
HY2XS_TLS_MODE=acme HY2XS_TLS_MODE=acme
HY2XS_ACME_TYPE=http HY2XS_ACME_TYPE=http
+7
View File
@@ -7,6 +7,7 @@ require_repo_layout() {
[ -f "orchestrator/bun.lock" ] || fail "missing orchestrator/bun.lock" [ -f "orchestrator/bun.lock" ] || fail "missing orchestrator/bun.lock"
[ -f "package/install.sh" ] || fail "missing package/install.sh" [ -f "package/install.sh" ] || fail "missing package/install.sh"
[ -d "package/templates" ] || fail "missing package/templates" [ -d "package/templates" ] || fail "missing package/templates"
[ -f "package/config/hy2xs.env" ] || fail "missing package/config/hy2xs.env"
[ -f "package/templates/env/post-install.env.tpl" ] || fail "missing package/templates/env/post-install.env.tpl" [ -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 "package/systemd" ] || fail "missing package/systemd"
[ -d "apps" ] || fail "missing apps HY2XS admin source" [ -d "apps" ] || fail "missing apps HY2XS admin source"
@@ -31,6 +32,12 @@ verify_archive() {
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/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/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/hysteria/config.yaml.tpl$' || fail "archive missing Hysteria config template"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/config/hy2xs.env$' || fail "archive missing canonical runtime config"
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/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" printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/checksums.txt$' || fail "archive missing checksums"
local env_content
env_content="$(tar -xOzf "$archive" hy2xs-install/config/hy2xs.env)"
printf '%s\n' "$env_content" | grep -q 'replace-with-your-domain.example' && fail "packaged hy2xs.env contains placeholder domain"
printf '%s\n' "$env_content" | grep -q 'replace-with-your-email@example.com' && fail "packaged hy2xs.env contains placeholder email"
} }