From 78da898ba567f7e7d81125165558f4390b8143f3 Mon Sep 17 00:00:00 2001 From: Crimson Date: Sat, 9 May 2026 02:46:48 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20producti?= =?UTF-8?q?on-=D1=84=D0=B8=D0=BA=D1=81=20fix30/fix30.1:=20auth,=20smoke,?= =?UTF-8?q?=20bootstrap,=20preflight,=20rollback=20=D0=B8=20=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20PATCH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/controller/peer.go | 1 + apps/controller/validator.go | 1 + apps/dao/sqlite.go | 122 +++++++++++++++++++++ apps/service/hysteria2_api.go | 4 - orchestrator/src/commands/install.ts | 5 +- orchestrator/src/commands/reconfigure.ts | 2 +- orchestrator/src/platform/assert.ts | 11 +- orchestrator/src/steps/preflight.ts | 16 ++- orchestrator/src/steps/smoke.ts | 12 +- package/templates/env/post-install.env.tpl | 2 +- package/templates/hysteria/config.yaml.tpl | 2 +- tools/build/lib/acceptance.sh | 9 +- tools/build/lib/verify.sh | 10 +- 13 files changed, 177 insertions(+), 20 deletions(-) diff --git a/apps/controller/peer.go b/apps/controller/peer.go index 0034dbc..1f295f3 100644 --- a/apps/controller/peer.go +++ b/apps/controller/peer.go @@ -92,6 +92,7 @@ func UpdatePeer(c *gin.Context) { if err != nil { return } + peerUpdateDto.Id = &id if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" && service.ExistPeerName(*peerUpdateDto.Name, id) { vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c) return diff --git a/apps/controller/validator.go b/apps/controller/validator.go index 05eb9b5..8f72752 100644 --- a/apps/controller/validator.go +++ b/apps/controller/validator.go @@ -31,6 +31,7 @@ func validateField[T interface{}](c *gin.Context, field T) (T, error) { bindErr = c.ShouldBindQuery(&field) } else if c.Request.Method == http.MethodPost || c.Request.Method == http.MethodPut || + c.Request.Method == http.MethodPatch || c.Request.Method == http.MethodDelete { bindErr = c.ShouldBindJSON(&field) } diff --git a/apps/dao/sqlite.go b/apps/dao/sqlite.go index ab1be53..7179097 100644 --- a/apps/dao/sqlite.go +++ b/apps/dao/sqlite.go @@ -1,6 +1,7 @@ package dao import ( + "encoding/base64" "errors" "fmt" "github.com/glebarez/sqlite" @@ -63,6 +64,9 @@ func InitSql(port string) error { if err := ensureSecureBootstrapAdmin(); err != nil { return err } + if err := ensureSecureBootstrapPeer(); err != nil { + return err + } if _, err := getOrCreateJwtSecret(); err != nil { return err } @@ -143,6 +147,124 @@ func ensureSecureBootstrapAdmin() error { return nil } +func ensureSecureBootstrapPeer() error { + bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS")) + if bootstrapSecret == "" { + generated, err := util.RandomString(24) + if err != nil { + return err + } + bootstrapSecret = generated + } + + if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil { + return nil + } + if _, err := GetPeer("name = ?", "bootstrap-admin-peer"); err == nil { + return nil + } + + secretDigest, err := buildPeerSecretDigest(bootstrapSecret) + if err != nil { + return err + } + + if _, err := GetPeer("secret_digest = ?", secretDigest); err == nil { + return nil + } + + secretEncrypted, err := encryptBootstrapPeerSecret(bootstrapSecret) + if err != nil { + return err + } + + name := "bootstrap-admin-peer" + authID := "bootstrap-admin-peer" + remark := "bootstrap peer seeded from HY2XS_ADMIN_CON_PASS" + quota := int64(0) + download := int64(0) + upload := int64(0) + expires := int64(0) + maxDevices := int64(3) + disabled := int64(0) + bannedUntil := int64(0) + lastConnection := int64(0) + + peer := entity.Peer{ + Name: &name, + Remark: &remark, + AuthId: &authID, + SecretDigest: &secretDigest, + SecretEncrypted: &secretEncrypted, + QuotaBytes: "a, + DownloadBytes: &download, + UploadBytes: &upload, + ExpiresAt: &expires, + MaxDevices: &maxDevices, + Disabled: &disabled, + BannedUntil: &bannedUntil, + LastConnectionAt: &lastConnection, + } + _, saveErr := SavePeer(peer) + return saveErr +} + +func buildPeerSecretDigest(rawSecret string) (string, error) { + secretKey, err := getOrCreatePeerSecretDigestKey() + if err != nil { + return "", err + } + return util.HmacSHA256Hex(rawSecret, secretKey), nil +} + +func getOrCreatePeerSecretEncryptionKey() ([]byte, error) { + raw, err := getOrCreateConfigKey(constant.PeerSecretEncryptionKey, 32) + if err != nil { + return nil, err + } + decoded, decErr := util.DecodeBase64Key(raw, 32) + if decErr == nil { + return decoded, nil + } + plain := []byte(strings.TrimSpace(raw)) + if len(plain) < 32 { + return nil, errors.New("invalid peer secret encryption key") + } + plain = plain[:32] + encoded := base64.StdEncoding.EncodeToString(plain) + if err := UpdateConfig([]string{constant.PeerSecretEncryptionKey}, map[string]interface{}{"value": encoded}); err != nil { + return nil, err + } + return plain, nil +} + +func encryptBootstrapPeerSecret(rawSecret string) (string, error) { + key, err := getOrCreatePeerSecretEncryptionKey() + if err != nil { + return "", err + } + return util.EncryptAESGCM(rawSecret, key) +} + +func getOrCreateConfigKey(key string, size int) (string, error) { + cfg, err := GetConfig("key = ?", key) + if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" { + return strings.TrimSpace(*cfg.Value), nil + } + raw, genErr := util.RandomString(size) + if genErr != nil { + return "", genErr + } + value := raw + remark := key + if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); saveErr != nil { + if updErr := UpdateConfig([]string{key}, map[string]interface{}{"value": value}); updErr != nil { + return "", updErr + } + } + return value, nil +} + func runMigrations() error { if tx := sqliteDB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, diff --git a/apps/service/hysteria2_api.go b/apps/service/hysteria2_api.go index e4badb4..292ad06 100644 --- a/apps/service/hysteria2_api.go +++ b/apps/service/hysteria2_api.go @@ -31,10 +31,6 @@ func resolvePublicEndpoint() (string, int, error) { } func Hysteria2Auth(conPass string) (int64, string, error) { - if !Hysteria2IsRunning() { - return 0, "", errors.New("hysteria2 is not running") - } - now := time.Now().UnixMilli() secretDigest, digestErr := PeerSecretDigest(conPass) if digestErr != nil { diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index f20c083..e419061 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -177,13 +177,16 @@ export async function install(options: InstallOptions): Promise { await advanceInstallState(context, "installing"); state.lastPhase = "installing"; step("preflight"); - await preflight(context); + await preflight(context, { requireCapabilities: false }); stepDone("preflight"); await advanceInstallState(context, "preflight_ok"); state.lastPhase = "preflight_ok"; step("system dependencies"); await installDeps(context); stepDone("system dependencies"); + step("preflight capabilities"); + await preflight(context, { requireCapabilities: true }); + stepDone("preflight capabilities"); await advanceInstallState(context, "deps_ok"); state.lastPhase = "deps_ok"; step("filesystem"); diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index 2bbeeb1..57bda6b 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -100,7 +100,7 @@ async function rollbackCurrentState(): Promise { await runVisible`if [ -f /etc/hy2xs/backups/hy2xs.env.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.env.bak /etc/hy2xs/hy2xs.env 2>/dev/null || true; else rm -f /etc/hy2xs/hy2xs.env; fi`; await runVisible`if [ -f /etc/hy2xs/backups/post-install.env.existed ]; then cp -a /etc/hy2xs/backups/post-install.env.bak /etc/hysteria/post-install.env 2>/dev/null || true; else rm -f /etc/hysteria/post-install.env; fi`; - await runVisible`if [ -f /etc/hy2xs/backups/nftables.conf.existed ]; then cp -a /etc/hy2xs/backups/nftables.conf.bak /etc/nftables.conf 2>/dev/null || true; fi`; + await runVisible`if [ -f /etc/hy2xs/backups/nftables.conf.existed ]; then cp -a /etc/hy2xs/backups/nftables.conf.bak /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`; await runVisible`if [ -f /etc/hy2xs/backups/hy2xs.nft.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`; await runVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`; diff --git a/orchestrator/src/platform/assert.ts b/orchestrator/src/platform/assert.ts index de43fd6..eb1d4da 100644 --- a/orchestrator/src/platform/assert.ts +++ b/orchestrator/src/platform/assert.ts @@ -5,6 +5,9 @@ type AssertPlatformOptions = { distro: "debian"; supportedVersions: number[]; architectures: Array<"amd64">; + requireSystemdRun?: boolean; + requireNftables?: boolean; + requireOpenSsl3?: boolean; }; export async function assertPlatform(options: AssertPlatformOptions): Promise { @@ -29,16 +32,16 @@ export async function assertPlatform(options: AssertPlatformOptions): Promise { } } -export async function preflight(context: RuntimeContext): Promise { +export async function preflight(context: RuntimeContext, options?: PreflightOptions): Promise { const isReconfigure = context.mode === "reconfigure"; + const requireCapabilities = options?.requireCapabilities ?? true; + + const needsFirewallCapabilities = !context.options.skipFirewall && + context.config.firewallMode !== "off" && + context.config.firewallMode !== "external"; if (process.getuid?.() !== 0) { fail("installer must run as root"); @@ -51,7 +60,10 @@ export async function preflight(context: RuntimeContext): Promise { await assertPlatform({ distro: "debian", supportedVersions: [13], - architectures: ["amd64"] + architectures: ["amd64"], + requireSystemdRun: requireCapabilities, + requireNftables: requireCapabilities && needsFirewallCapabilities, + requireOpenSsl3: requireCapabilities }); if (!(await fileExists(`${context.options.packageDir}/systemd/hy2xs-admin.service`))) { diff --git a/orchestrator/src/steps/smoke.ts b/orchestrator/src/steps/smoke.ts index 768464f..e6ea464 100644 --- a/orchestrator/src/steps/smoke.ts +++ b/orchestrator/src/steps/smoke.ts @@ -144,23 +144,27 @@ export async function smoke(context: RuntimeContext): Promise { } await runVisible`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`; await runVisible`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`; + const missingTokenAuthCode = await runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -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 (missingTokenAuthCode.trim() !== "403") { + throw new Error(`unexpected auth status without machine token: ${missingTokenAuthCode}`); + } const invalidAuthResponse = await retry( "auth invalid credentials", 5, 1000, - async () => 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`, + async () => 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?access_token=${context.config.hysteriaTrafficStatsSecret}'`, (response) => /"ok"\s*:\s*false/.test(response), (response, error) => new Error(`unexpected auth response for invalid credentials: ${response ?? String(error)}`), ); for (let i = 0; i < 10; i += 1) { - const response = 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`; + const response = 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?access_token=${context.config.hysteriaTrafficStatsSecret}'`; if (!/"ok"\s*:\s*false/.test(response)) { throw new Error(`unexpected auth response during rate-limit smoke: ${response}`); } } - const invalidTypeAuthCode = await runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -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`; + const invalidTypeAuthCode = await runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -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?access_token=${context.config.hysteriaTrafficStatsSecret}'`; if (invalidTypeAuthCode.trim() !== "400") { throw new Error(`unexpected auth status for tx as string: ${invalidTypeAuthCode}`); } @@ -175,7 +179,7 @@ export async function smoke(context: RuntimeContext): Promise { "auth valid credentials", 10, 1000, - async () => 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`, + async () => 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?access_token=${context.config.hysteriaTrafficStatsSecret}'`, (response) => /"ok"\s*:\s*true/.test(response), (response, error) => new Error(`unexpected auth response for valid credentials: ${response ?? String(error)}`), ); diff --git a/package/templates/env/post-install.env.tpl b/package/templates/env/post-install.env.tpl index 3eb9dc1..364f6e1 100644 --- a/package/templates/env/post-install.env.tpl +++ b/package/templates/env/post-install.env.tpl @@ -28,7 +28,7 @@ HY2_TLS_KEY_PATH={{TLS_KEY_PATH}} HY2_LISTEN_HOST={{HYSTERIA_BIND_HOST}} HY2_PORT={{HYSTERIA_PORT}} HY2_AUTH_MODE=http -HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth +HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}} HY2_TRAFFIC_STATS_LISTEN={{HYSTERIA_API_HOST}}:{{HYSTERIA_API_PORT}} HY2_OBFS_TYPE=salamander HY2_OBFS_PASSWORD={{HYSTERIA_OBFS_PASSWORD}} diff --git a/package/templates/hysteria/config.yaml.tpl b/package/templates/hysteria/config.yaml.tpl index 70a2290..66ecf30 100644 --- a/package/templates/hysteria/config.yaml.tpl +++ b/package/templates/hysteria/config.yaml.tpl @@ -6,7 +6,7 @@ listen: {{HYSTERIA_BIND_HOST}}:{{HYSTERIA_PORT}} auth: type: http http: - url: http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth + url: http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}} insecure: {{AUTH_INSECURE}} obfs: diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 1488b9f..51b2079 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -20,5 +20,12 @@ run_fix20_acceptance_subset() { log_step "Acceptance: docs matrix markers" grep -q 'Fix20 production matrix' docs/11-testing-and-acceptance.md || fail "acceptance: fix20 matrix section missing" -} + log_step "Acceptance: machine auth URL in templates" + grep -q '/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template" + grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}$' "$package_dir/templates/env/post-install.env.tpl" || fail "acceptance: machine token missing in post-install HY2_AUTH_URL" + + log_step "Acceptance: smoke auth checks are tokenized" + grep -q 'unexpected auth status without machine token' orchestrator/src/steps/smoke.ts || fail "acceptance: missing 403 negative smoke for auth without machine token" + grep -q 'hysteria2/auth?access_token=\${context.config.hysteriaTrafficStatsSecret}' orchestrator/src/steps/smoke.ts || fail "acceptance: smoke auth URL is not tokenized" +} diff --git a/tools/build/lib/verify.sh b/tools/build/lib/verify.sh index 100910e..ff91147 100644 --- a/tools/build/lib/verify.sh +++ b/tools/build/lib/verify.sh @@ -58,6 +58,14 @@ verify_archive() { 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" + local hysteria_tpl + hysteria_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/hysteria/config.yaml.tpl)" + printf '%s\n' "$hysteria_tpl" | grep -q '/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}' || fail "hysteria auth template must include machine access_token" + + local post_install_tpl + post_install_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/env/post-install.env.tpl)" + printf '%s\n' "$post_install_tpl" | grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}$' || fail "post-install env template must include machine access_token in HY2_AUTH_URL" + local tmp tmp="$(mktemp -d)" tar -xzf "$archive" -C "$tmp" @@ -65,7 +73,7 @@ verify_archive() { [ -x "$tmp/hy2xs-install/orchestrator/hy2xs-orchestrator" ] || fail "orchestrator is not executable" [ -x "$tmp/hy2xs-install/ui/hy2xs-admin/hy2xs-admin" ] || fail "hy2xs-admin is not executable" - "$tmp/hy2xs-install/orchestrator/hy2xs-orchestrator" status --package-dir "$tmp/hy2xs-install" >/dev/null 2>&1 || true + "$tmp/hy2xs-install/orchestrator/hy2xs-orchestrator" status --package-dir "$tmp/hy2xs-install" >/dev/null 2>&1 || fail "orchestrator status sanity check failed" local meta meta="$(cat "$tmp/hy2xs-install/metadata/package.env")"