From a41f14067b7191b8aab3d515ef9f7376f722062b Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 30 Apr 2026 21:50:03 +0500 Subject: [PATCH] fix11: runtime contracts and smoke stability --- apps/controller/hysteria2.go | 62 ++------------------------------- apps/middleware/filter.go | 17 ++++++--- apps/middleware/local_only.go | 9 +++-- apps/middleware/rate_limiter.go | 10 ++++-- apps/router/router.go | 1 - docs/09-post-install-env.md | 7 ++++ orchestrator/src/config/env.ts | 23 +++++++++--- orchestrator/src/steps/smoke.ts | 51 ++++++++++++++++++++++----- tools/build/hysteria-lock.env | 6 ++-- tools/build/lib/package.sh | 7 ++++ 10 files changed, 105 insertions(+), 88 deletions(-) diff --git a/apps/controller/hysteria2.go b/apps/controller/hysteria2.go index a0d70cb..a55dcf0 100644 --- a/apps/controller/hysteria2.go +++ b/apps/controller/hysteria2.go @@ -1,16 +1,12 @@ package controller import ( - "encoding/base64" "github.com/gin-gonic/gin" "github.com/skip2/go-qrcode" - "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/entity" "hy2xs-admin/model/vo" "hy2xs-admin/service" - "net/url" - "strings" "time" ) @@ -83,63 +79,9 @@ func Hysteria2Url(c *gin.Context) { } func Hysteria2SubscribeUrl(c *gin.Context) { - hysteria2SubscribeUrlDto, err := validateField(c, dto.Hysteria2SubscribeUrlDto{}) - if err != nil { - return - } - subscribeUrl, err := service.Hysteria2SubscribeUrl(*hysteria2SubscribeUrlDto.AccountId, - *hysteria2SubscribeUrlDto.Protocol) - if err != nil { - vo.Fail(err.Error(), c) - return - } - qrCode, err := qrcode.Encode(subscribeUrl, qrcode.Medium, 300) - if err != nil { - vo.Fail(err.Error(), c) - return - } - subscribeVo := vo.Hysteria2SubscribeVo{ - Url: subscribeUrl, - QrCode: qrCode, - } - vo.Success(subscribeVo, c) + vo.Fail("subscription delivery is out of scope in HY2XS baseline", c) } func Hysteria2Subscribe(c *gin.Context) { - conPass := c.Param("conPass") - conPass, err := url.QueryUnescape(conPass) - if err != nil { - vo.Fail("url decode err", c) - return - } - userAgent := strings.ToLower(c.Request.Header.Get("User-Agent")) - - var clientType string - if strings.Contains(userAgent, constant.Shadowrocket) { - clientType = constant.Shadowrocket - } else if strings.Contains(userAgent, constant.Clash) { - clientType = constant.Clash - } else if strings.Contains(userAgent, constant.V2rayN) { - clientType = constant.V2rayN - } else if strings.Contains(userAgent, constant.NekoBox) { - clientType = constant.NekoBox - } else { - clientType = constant.Clash - } - - userInfo, configStr, err := service.Hysteria2Subscribe(conPass, clientType) - if err != nil { - vo.Fail(err.Error(), c) - return - } - - if clientType == constant.Shadowrocket || clientType == constant.Clash { - c.Header("content-disposition", "attachment; filename=hui.yaml") - c.Header("profile-update-interval", "12") - c.Header("subscription-userinfo", userInfo) - } else if clientType == constant.V2rayN { - configStr = base64.StdEncoding.EncodeToString([]byte(configStr)) - } - - c.String(200, configStr) + vo.Fail("subscription delivery is out of scope in HY2XS baseline", c) } diff --git a/apps/middleware/filter.go b/apps/middleware/filter.go index 2cc7a51..16b7941 100644 --- a/apps/middleware/filter.go +++ b/apps/middleware/filter.go @@ -2,7 +2,6 @@ package middleware import ( "github.com/gin-gonic/gin" - "hy2xs-admin/model/vo" "net/http" "regexp" ) @@ -11,13 +10,21 @@ func FilterHandler() gin.HandlerFunc { return func(c *gin.Context) { matched, err := regexp.MatchString(`(?i)fofa|shodan|curl|wget`, c.Request.UserAgent()) if err != nil { - vo.Fail("Internal error", c) - c.AbortWithStatus(http.StatusInternalServerError) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "type": "no", + "message": "Internal error", + "data": nil, + }) return } if matched { - vo.Fail("Forbidden: Scanning tools are not allowed", c) - c.AbortWithStatus(http.StatusForbidden) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "code": http.StatusForbidden, + "type": "no", + "message": "Forbidden: Scanning tools are not allowed", + "data": nil, + }) return } c.Next() diff --git a/apps/middleware/local_only.go b/apps/middleware/local_only.go index 63dbd1a..714225d 100644 --- a/apps/middleware/local_only.go +++ b/apps/middleware/local_only.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/gin-gonic/gin" - "hy2xs-admin/model/vo" ) func LocalOnlyHandler() gin.HandlerFunc { @@ -16,8 +15,12 @@ func LocalOnlyHandler() gin.HandlerFunc { } if host != "127.0.0.1" && host != "::1" { - vo.Fail("local access only", c) - c.AbortWithStatus(http.StatusForbidden) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "code": http.StatusForbidden, + "type": "no", + "message": "local access only", + "data": nil, + }) return } diff --git a/apps/middleware/rate_limiter.go b/apps/middleware/rate_limiter.go index eeb7c77..867427d 100644 --- a/apps/middleware/rate_limiter.go +++ b/apps/middleware/rate_limiter.go @@ -4,7 +4,7 @@ import ( "github.com/didip/tollbooth" "github.com/didip/tollbooth/limiter" "github.com/gin-gonic/gin" - "hy2xs-admin/model/vo" + "net/http" ) var limit *limiter.Limiter @@ -17,8 +17,12 @@ func RateLimiterHandler() gin.HandlerFunc { return func(c *gin.Context) { httpError := tollbooth.LimitByRequest(limit, c.Writer, c.Request) if httpError != nil { - vo.Fail("click too fast", c) - c.Abort() + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "code": http.StatusTooManyRequests, + "type": "no", + "message": "click too fast", + "data": nil, + }) return } c.Next() diff --git a/apps/router/router.go b/apps/router/router.go index ae5d3ea..2146de4 100644 --- a/apps/router/router.go +++ b/apps/router/router.go @@ -24,7 +24,6 @@ func Router(router *gin.Engine, huiWebContext *string) { authApi := globalGroup.Group("/hui") authApi.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler()) initAuthRouter(authApi) - initHysteria2SubscribeRouter(authApi) huiAdminApi := globalGroup.Group("/hui") huiAdminApi.Use( diff --git a/docs/09-post-install-env.md b/docs/09-post-install-env.md index 2cac385..06c4cc1 100644 --- a/docs/09-post-install-env.md +++ b/docs/09-post-install-env.md @@ -113,6 +113,13 @@ - `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован); - после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`. +### Immutable-bootstrap контракт + +- `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке. +- На `reconfigure --apply` bootstrap secret не пересоздаётся и не ротируется автоматически. +- Изменения `HY2XS_ADMIN_INITIAL_PASSWORD` в runtime env после первичной установки не должны менять фактический пароль admin. +- Изменения `HY2XS_ADMIN_CON_PASS` применяются только через runtime-конфигурацию Hysteria/auth-контур и не переписывают bootstrap snapshot. + ## Что нельзя делать - сваливать туда временный мусор diff --git a/orchestrator/src/config/env.ts b/orchestrator/src/config/env.ts index d4df929..1b2ba1c 100644 --- a/orchestrator/src/config/env.ts +++ b/orchestrator/src/config/env.ts @@ -74,13 +74,28 @@ function normalizeIpv4Host(name: string, value: string): string { } function normalizePublicHost(value: string): string { - if (!value) { + const host = value.trim(); + + if (!host) { throw new Error("missing required HY2XS_PUBLIC_HOST"); } - if (value.includes(":")) { - throw new Error("HY2XS_PUBLIC_HOST must not contain IPv6"); + + if (host.includes("/") || host.includes(":") || /\s/.test(host)) { + throw new Error("HY2XS_PUBLIC_HOST must be a domain or IPv4 without scheme, port, path or spaces"); } - return value; + + const ipv4 = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/; + const domain = /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + + if (!ipv4.test(host) && !domain.test(host)) { + throw new Error(`invalid HY2XS_PUBLIC_HOST: ${value}`); + } + + if (host === "0.0.0.0" || host === "127.0.0.1") { + throw new Error("HY2XS_PUBLIC_HOST must be a routable domain or public IPv4"); + } + + return host; } function normalizeTlsMode(value: string): TlsMode { diff --git a/orchestrator/src/steps/smoke.ts b/orchestrator/src/steps/smoke.ts index e9442a6..9cd7182 100644 --- a/orchestrator/src/steps/smoke.ts +++ b/orchestrator/src/steps/smoke.ts @@ -2,6 +2,27 @@ import type { RuntimeContext } from "../types/context"; import { info } from "../lib/log"; import { runHidden, runSecret, runVisible } from "../lib/process"; +async function retry( + attempts: number, + delayMs: number, + action: () => Promise, + validate: (value: T) => boolean, + errorFactory: (value: T) => Error, +): Promise { + let lastValue: T | undefined; + for (let i = 0; i < attempts; i += 1) { + const value = await action(); + lastValue = value; + if (validate(value)) { + return value; + } + if (i < attempts - 1) { + await runHidden`sleep ${Math.max(1, Math.ceil(delayMs / 1000))}`; + } + } + throw errorFactory(lastValue as T); +} + export async function smoke(context: RuntimeContext): Promise { if (context.options.skipStart) { info("service start and smoke checks skipped by flag"); @@ -40,10 +61,13 @@ export async function smoke(context: RuntimeContext): Promise { await runVisible`ss -H -lun | grep -q '0.0.0.0:${context.config.hysteriaPort} '`; await runVisible`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`; await runVisible`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`; - 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 invalidAuthResponse = await retry( + 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`, + (response) => /"ok"\s*:\s*false/.test(response), + (response) => new Error(`unexpected auth response for invalid credentials: ${response}`), + ); 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`; @@ -57,12 +81,21 @@ export async function smoke(context: RuntimeContext): Promise { 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)) { - throw new Error(`unexpected auth response for valid credentials`); - } + const validAuthResponse = await retry( + 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`, + (response) => /"ok"\s*:\s*true/.test(response), + () => 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 retry( + 10, + 1000, + async () => runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`, + (code) => /^2\d\d$/.test(code.trim()), + (code) => new Error(`unexpected trafficStats status for valid secret: ${code}`), + ); 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/tools/build/hysteria-lock.env b/tools/build/hysteria-lock.env index a707234..b637015 100644 --- a/tools/build/hysteria-lock.env +++ b/tools/build/hysteria-lock.env @@ -1,3 +1,3 @@ -HYSTERIA_VERSION=v2.6.0 -HYSTERIA_ARTIFACT_URL=https://github.com/apernet/hysteria/releases/download/app%2Fv2.6.0/hysteria-linux-amd64 -HYSTERIA_ARTIFACT_SHA256=replace-with-release-sha256 +HYSTERIA_VERSION=v2.8.2 +HYSTERIA_ARTIFACT_URL=https://github.com/apernet/hysteria/releases/download/app%2Fv2.8.2/hysteria-linux-amd64 +HYSTERIA_ARTIFACT_SHA256=b11bf0fb5f84a3f5c6baff3696e899539e68af4cee868c9203cfb896784ad3b0 diff --git a/tools/build/lib/package.sh b/tools/build/lib/package.sh index 3f3c4a7..177338c 100644 --- a/tools/build/lib/package.sh +++ b/tools/build/lib/package.sh @@ -69,6 +69,13 @@ write_metadata() { [ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION is required in $HYSTERIA_LOCK_FILE" [ -n "${HYSTERIA_ARTIFACT_URL:-}" ] || fail "HYSTERIA_ARTIFACT_URL is required in $HYSTERIA_LOCK_FILE" [ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 is required in $HYSTERIA_LOCK_FILE" + case "$HYSTERIA_ARTIFACT_SHA256" in + replace-with-*|"") + fail "HYSTERIA_ARTIFACT_SHA256 must be a real release sha256" + ;; + esac + printf '%s' "$HYSTERIA_ARTIFACT_SHA256" | grep -Eq '^[a-fA-F0-9]{64}$' \ + || fail "HYSTERIA_ARTIFACT_SHA256 must be a 64-char hex SHA256" { printf 'name=HY2XS\n'