fix11: runtime contracts and smoke stability

This commit is contained in:
2026-04-30 21:50:03 +05:00
parent 4a0d9569d1
commit a41f14067b
10 changed files with 105 additions and 88 deletions
+2 -60
View File
@@ -1,16 +1,12 @@
package controller package controller
import ( import (
"encoding/base64"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
"net/url"
"strings"
"time" "time"
) )
@@ -83,63 +79,9 @@ func Hysteria2Url(c *gin.Context) {
} }
func Hysteria2SubscribeUrl(c *gin.Context) { func Hysteria2SubscribeUrl(c *gin.Context) {
hysteria2SubscribeUrlDto, err := validateField(c, dto.Hysteria2SubscribeUrlDto{}) vo.Fail("subscription delivery is out of scope in HY2XS baseline", c)
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)
} }
func Hysteria2Subscribe(c *gin.Context) { func Hysteria2Subscribe(c *gin.Context) {
conPass := c.Param("conPass") vo.Fail("subscription delivery is out of scope in HY2XS baseline", c)
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)
} }
+12 -5
View File
@@ -2,7 +2,6 @@ package middleware
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/model/vo"
"net/http" "net/http"
"regexp" "regexp"
) )
@@ -11,13 +10,21 @@ func FilterHandler() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
matched, err := regexp.MatchString(`(?i)fofa|shodan|curl|wget`, c.Request.UserAgent()) matched, err := regexp.MatchString(`(?i)fofa|shodan|curl|wget`, c.Request.UserAgent())
if err != nil { if err != nil {
vo.Fail("Internal error", c) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
c.AbortWithStatus(http.StatusInternalServerError) "code": http.StatusInternalServerError,
"type": "no",
"message": "Internal error",
"data": nil,
})
return return
} }
if matched { if matched {
vo.Fail("Forbidden: Scanning tools are not allowed", c) c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
c.AbortWithStatus(http.StatusForbidden) "code": http.StatusForbidden,
"type": "no",
"message": "Forbidden: Scanning tools are not allowed",
"data": nil,
})
return return
} }
c.Next() c.Next()
+6 -3
View File
@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/model/vo"
) )
func LocalOnlyHandler() gin.HandlerFunc { func LocalOnlyHandler() gin.HandlerFunc {
@@ -16,8 +15,12 @@ func LocalOnlyHandler() gin.HandlerFunc {
} }
if host != "127.0.0.1" && host != "::1" { if host != "127.0.0.1" && host != "::1" {
vo.Fail("local access only", c) c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
c.AbortWithStatus(http.StatusForbidden) "code": http.StatusForbidden,
"type": "no",
"message": "local access only",
"data": nil,
})
return return
} }
+7 -3
View File
@@ -4,7 +4,7 @@ import (
"github.com/didip/tollbooth" "github.com/didip/tollbooth"
"github.com/didip/tollbooth/limiter" "github.com/didip/tollbooth/limiter"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/model/vo" "net/http"
) )
var limit *limiter.Limiter var limit *limiter.Limiter
@@ -17,8 +17,12 @@ func RateLimiterHandler() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
httpError := tollbooth.LimitByRequest(limit, c.Writer, c.Request) httpError := tollbooth.LimitByRequest(limit, c.Writer, c.Request)
if httpError != nil { if httpError != nil {
vo.Fail("click too fast", c) c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
c.Abort() "code": http.StatusTooManyRequests,
"type": "no",
"message": "click too fast",
"data": nil,
})
return return
} }
c.Next() c.Next()
-1
View File
@@ -24,7 +24,6 @@ func Router(router *gin.Engine, huiWebContext *string) {
authApi := globalGroup.Group("/hui") authApi := globalGroup.Group("/hui")
authApi.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler()) authApi.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler())
initAuthRouter(authApi) initAuthRouter(authApi)
initHysteria2SubscribeRouter(authApi)
huiAdminApi := globalGroup.Group("/hui") huiAdminApi := globalGroup.Group("/hui")
huiAdminApi.Use( huiAdminApi.Use(
+7
View File
@@ -113,6 +113,13 @@
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован); - `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`. - после первичного 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.
## Что нельзя делать ## Что нельзя делать
- сваливать туда временный мусор - сваливать туда временный мусор
+19 -4
View File
@@ -74,13 +74,28 @@ function normalizeIpv4Host(name: string, value: string): string {
} }
function normalizePublicHost(value: string): string { function normalizePublicHost(value: string): string {
if (!value) { const host = value.trim();
if (!host) {
throw new Error("missing required HY2XS_PUBLIC_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 { function normalizeTlsMode(value: string): TlsMode {
+42 -9
View File
@@ -2,6 +2,27 @@ import type { RuntimeContext } from "../types/context";
import { info } from "../lib/log"; import { info } from "../lib/log";
import { runHidden, runSecret, runVisible } from "../lib/process"; import { runHidden, runSecret, runVisible } from "../lib/process";
async function retry<T>(
attempts: number,
delayMs: number,
action: () => Promise<T>,
validate: (value: T) => boolean,
errorFactory: (value: T) => Error,
): Promise<T> {
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<void> { export async function smoke(context: RuntimeContext): Promise<void> {
if (context.options.skipStart) { if (context.options.skipStart) {
info("service start and smoke checks skipped by flag"); info("service start and smoke checks skipped by flag");
@@ -40,10 +61,13 @@ export async function smoke(context: RuntimeContext): 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 -ltn | grep -q '\[::\]:${context.config.uiPort} '`; await runVisible`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`;
await runVisible`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`; 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`; const invalidAuthResponse = await retry(
if (!/"ok"\s*:\s*false/.test(invalidAuthResponse)) { 5,
throw new Error(`unexpected auth response for invalid credentials: ${invalidAuthResponse}`); 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) { 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`;
@@ -57,12 +81,21 @@ export async function smoke(context: RuntimeContext): Promise<void> {
throw new Error("admin connection password is empty in bootstrap secret file"); 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`; const validAuthResponse = await retry(
if (!/"ok"\s*:\s*true/.test(validAuthResponse)) { 10,
throw new Error(`unexpected auth response for valid credentials`); 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`; 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}`);
+3 -3
View File
@@ -1,3 +1,3 @@
HYSTERIA_VERSION=v2.6.0 HYSTERIA_VERSION=v2.8.2
HYSTERIA_ARTIFACT_URL=https://github.com/apernet/hysteria/releases/download/app%2Fv2.6.0/hysteria-linux-amd64 HYSTERIA_ARTIFACT_URL=https://github.com/apernet/hysteria/releases/download/app%2Fv2.8.2/hysteria-linux-amd64
HYSTERIA_ARTIFACT_SHA256=replace-with-release-sha256 HYSTERIA_ARTIFACT_SHA256=b11bf0fb5f84a3f5c6baff3696e899539e68af4cee868c9203cfb896784ad3b0
+7
View File
@@ -69,6 +69,13 @@ write_metadata() {
[ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION is required in $HYSTERIA_LOCK_FILE" [ -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_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" [ -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' printf 'name=HY2XS\n'