feat(v1): Gecko-обфускация, latest-stable Hysteria на сборке и forward-compatible admin

Сквозная миграция HY2XS на современную Hysteria (2.12.2) и переход на v1.

Build:
- версия Hysteria резолвится на этапе сборки из HyNetworks/hysteria и
  замораживается в metadata пакета (version + immutable url + sha256);
- compatibility gate: реальный бинарник должен принять канонический конфиг
  HY2XS для gecko и salamander до создания пакета;
- сборка прогоняет тесты оркестратора и админки.

Конфигурационный контракт:
- HY2XS_CONFIG_SCHEMA_VERSION=2, чужая схема отклоняется fail-fast;
- obfs стал настоящим union gecko|salamander, gecko — default;
- obfs-блок рендерится оркестратором целиком, два подтипа одновременно
  структурно невозможны;
- современный baseline: congestion bbr/standard, disableLossCompensation=false,
  disableStatelessReset=false, полный quic-блок.

Исправления:
- share URI для gecko: генератор был завязан на Obfs.Salamander.Password и
  выдавал нерабочую ссылку при любой другой обфускации;
- SNI брался только из ACME-блока и уходил пустым при HY2XS_TLS_MODE=file;
- экспорт конфига выносил trafficStats.secret, access_token и obfs-пароль;
- экспорт терял неизвестные upstream-поля при round-trip через типизированную
  модель;
- renderRuntimeEnv печатал тип обфускации литералом, расходясь с конфигом;
- namedotcom удалён из ACME-реестра (нет в Hysteria с 2.11.0).

Тесты:
- 95 тестов оркестратора: env, рендер, семантика профиля, резолвер, rollover;
- тесты URI и экспорта в Go;
- tools/test/e2e-hysteria.sh с реальным клиентом Hysteria.

UX:
- подсказки и примеры в форме создания пира.

Прочее: CHANGELOG.md, .gitattributes (LF для target-side файлов),
документация на русском.
This commit is contained in:
2026-08-27 08:15:02 +05:00
parent 0205334cd8
commit ddf0ddf71e
53 changed files with 4827 additions and 291 deletions
+97 -34
View File
@@ -20,7 +20,10 @@ dist/hy2xs-install-<version>.tar.gz
- [`tools/build/lib/deps.sh`](lib/deps.sh) — проверка Debian/amd64, установка build dependencies, установка Go/Bun/Node.js/pnpm.
- [`tools/build/lib/package.sh`](lib/package.sh) — сборка orchestrator, сборка HY2XS admin, создание stage directory и tar.gz архива.
- [`tools/build/lib/verify.sh`](lib/verify.sh) — проверка структуры репозитория и итогового архива.
- [`tools/build/hysteria-lock.env`](hysteria-lock.env) — pinned версия, URL и SHA256 upstream Hysteria2 binary.
- [`tools/build/lib/acceptance.sh`](lib/acceptance.sh) — acceptance-проверки production-контракта.
- [`tools/build/lib/hysteria.sh`](lib/hysteria.sh) — разрешение upstream-версии Hysteria и compatibility gate.
- [`tools/build/hysteria-lock.env`](hysteria-lock.env) — fallback-значения для офлайн-сборки (`HYSTERIA_CHANNEL=pinned`).
- [`tools/test/e2e-hysteria.sh`](../test/e2e-hysteria.sh) — end-to-end проверка с реальным клиентом Hysteria.
- [`orchestrator`](../../orchestrator) — TypeScript/Bun install-only orchestrator.
- [`apps`](../../apps) — HY2XS admin: Go backend и Vue frontend.
- [`package`](../../package) — skeleton будущего install package: `install.sh`, templates, systemd units, default config.
@@ -58,13 +61,73 @@ Windows и macOS можно использовать для редактиров
- Bun `1.3.13`;
- Node.js `20.19.0`;
- pnpm `9.15.9`.
5. Собирает install-only orchestrator в standalone binary.
6. Собирает frontend HY2XS admin.
7. Собирает backend HY2XS admin в Linux amd64 binary.
5. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`).
6. Разрешает upstream-версию Hysteria, скачивает артефакт и считает SHA-256.
7. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
8. Копирует package skeleton.
9. Записывает metadata и checksums.
10. Создаёт `dist/hy2xs-install-<version>.tar.gz`.
11. Проверяет, что архив содержит обязательные файлы.
9. Собирает install-only orchestrator в standalone binary.
10. Собирает frontend и backend HY2XS admin в Linux amd64 binary.
11. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
12. Записывает metadata и checksums.
13. Создаёт `dist/hy2xs-install-<version>.tar.gz`.
14. Проверяет архив и прогоняет acceptance-проверки.
## Версия Hysteria: разрешение и compatibility gate
Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете.
Правила разрешения:
1. канонический upstream — `HyNetworks/hysteria`;
2. принимаются только стабильные релизы, без draft и prerelease;
3. тег должен иметь вид `app/vX.Y.Z`;
4. берётся ровно один артефакт `hysteria-linux-amd64`;
5. URL используется в том виде, в каком его вернул upstream API, без пересборки строки;
6. SHA-256 считается локально от скачанного файла;
7. разрешённые значения попадают в metadata пакета.
Сравнение версий числовое, поэтому `v2.9.10` считается новее `v2.9.2`.
После разрешения обязателен compatibility gate:
```text
скачать бинарник
сверить SHA-256 и `hysteria version`
отрендерить канонический конфиг HY2XS тем же кодом, что и на target
запустить настоящий Hysteria с этим конфигом (gecko и salamander)
только после этого собирать release package
```
При несовместимости сборка останавливается:
```text
BUILD FAILED: unsupported Hysteria stable v2.13.0
```
Это осознанное решение: ошибка должна проявиться на build machine, а не на production-сервере.
Переменные:
| Переменная | По умолчанию | Назначение |
| --- | --- | --- |
| `HYSTERIA_CHANNEL` | `stable` | `stable` — разрешить последнюю стабильную через upstream API; `pinned` — офлайн-сборка по `hysteria-lock.env` |
| `HYSTERIA_VERSION_OVERRIDE` | пусто | Закрепить конкретную версию `vX.Y.Z` |
| `HYSTERIA_COMPAT_GATE` | `true` | Compatibility gate; для release-сборок обязателен |
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в `hysteria-lock.env` |
| `HYSTERIA_GATE_PORT` | `34443` | UDP-порт для временного запуска Hysteria в gate |
| `HYSTERIA_GATE_STATS_PORT` | `34712` | TCP-порт trafficStats в gate |
| `GITHUB_TOKEN` | пусто | Опционально: снимает anonymous rate limit GitHub API |
| `SKIP_TESTS` | `false` | Аварийное отключение тестов; для release-сборок недопустимо |
Обновить lock-файл под текущий upstream:
```bash
HYSTERIA_WRITE_LOCK=true ./tools/build/build.sh
```
## Важное про Bun и старые CPU
@@ -111,7 +174,7 @@ BUN_FLAVOR=x64-baseline ./tools/build/build.sh
С явной версией и build id:
```bash
PACKAGE_VERSION=0.2.1 \
PACKAGE_VERSION=1.0.0 \
BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ) \
./tools/build/build.sh
```
@@ -121,59 +184,59 @@ BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ) \
Проверка архива:
```bash
ls -lh dist/hy2xs-install-0.2.1.tar.gz
sha256sum dist/hy2xs-install-0.2.1.tar.gz | tee dist/hy2xs-install-0.2.1.tar.gz.sha256
ls -lh dist/hy2xs-install-1.0.0.tar.gz
sha256sum dist/hy2xs-install-1.0.0.tar.gz | tee dist/hy2xs-install-1.0.0.tar.gz.sha256
```
Проверка обязательных файлов:
```bash
tar -tzf dist/hy2xs-install-0.2.1.tar.gz | grep -E '^(hy2xs-install/install.sh|hy2xs-install/orchestrator/hy2xs-orchestrator|hy2xs-install/ui/hy2xs-admin/hy2xs-admin|hy2xs-install/metadata/checksums.txt)$'
tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E '^(hy2xs-install/install.sh|hy2xs-install/orchestrator/hy2xs-orchestrator|hy2xs-install/ui/hy2xs-admin/hy2xs-admin|hy2xs-install/metadata/checksums.txt)$'
```
Проверка metadata:
```bash
tar -xOzf dist/hy2xs-install-0.2.1.tar.gz hy2xs-install/metadata/package.env
tar -xOzf dist/hy2xs-install-1.0.0.tar.gz hy2xs-install/metadata/package.env
```
## When to update orchestrator/bun.lock
## Когда обновлять orchestrator/bun.lock
Do not regenerate [`orchestrator/bun.lock`](../../orchestrator/bun.lock) during production builds.
Не перегенерируйте [`orchestrator/bun.lock`](../../orchestrator/bun.lock) во время production-сборки.
Update and commit [`orchestrator/bun.lock`](../../orchestrator/bun.lock) only when:
Обновлять и коммитить [`orchestrator/bun.lock`](../../orchestrator/bun.lock) следует только когда:
- [`orchestrator/package.json`](../../orchestrator/package.json) changes;
- `BUN_REQUIRED` changes in [`tools/build/lib/deps.sh`](lib/deps.sh);
- orchestrator dependencies are intentionally upgraded.
- изменился [`orchestrator/package.json`](../../orchestrator/package.json);
- изменился `BUN_REQUIRED` в [`tools/build/lib/deps.sh`](lib/deps.sh);
- зависимости оркестратора обновляются осознанно.
Production builder always runs:
Production builder всегда выполняет:
```bash
bun install --frozen-lockfile
```
If this command fails, fix and commit the lockfile in source control. Do not remove `--frozen-lockfile`.
Если команда падает, исправьте и закоммитьте lockfile в системе контроля версий. Не убирайте `--frozen-lockfile`.
## Frontend lockfile discipline
## Дисциплина lockfile для frontend
Do not regenerate frontend lock data during routine production builds.
Не перегенерируйте frontend lock data во время обычной production-сборки.
For frontend package management:
Правила управления пакетами frontend:
- [`apps/frontend/package.json`](../../apps/frontend/package.json) declares `"packageManager": "pnpm@9.15.9"`;
- builder uses pinned pnpm `9.15.9` from [`PNPM_REQUIRED`](lib/deps.sh);
- production frontend install path is always:
- [`apps/frontend/package.json`](../../apps/frontend/package.json) объявляет `"packageManager": "pnpm@9.15.9"`;
- builder использует закреплённый pnpm `9.15.9` из [`PNPM_REQUIRED`](lib/deps.sh);
- production-путь установки frontend всегда:
```bash
pnpm install --frozen-lockfile
```
If frozen install fails, update dependencies intentionally in source control and commit lockfile changes. Do not remove `--frozen-lockfile` from build flow.
Если frozen install падает, обновите зависимости осознанно в системе контроля версий и закоммитьте изменения lockfile. Не убирайте `--frozen-lockfile` из сборочного потока.
## Полезные переменные
- `PACKAGE_VERSION=0.2.1`
- `PACKAGE_VERSION=1.0.0`
- `BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ)`
- `BUN_FLAVOR=auto|x64|x64-baseline`
- `FRONTEND_NODE_OLD_SPACE_SIZE=2048` (default memory limit for frontend build step)
@@ -186,23 +249,23 @@ If frozen install fails, update dependencies intentionally in source control and
- `NODE_ARCHIVE_SHA256=<sha256>`
- `BUN_ARCHIVE_SHA256=<sha256>`
## Frontend memory policy
## Политика памяти при сборке frontend
Builder applies a safe default Node.js heap limit for frontend build inside [`bundle_ui()`](lib/package.sh):
Builder задаёт безопасный лимит heap для Node.js внутри [`bundle_ui()`](lib/package.sh):
```bash
--max-old-space-size=2048
```
Override options:
Способы переопределения:
- adjust default value for this policy:
- изменить значение по умолчанию для этой политики:
```bash
FRONTEND_NODE_OLD_SPACE_SIZE=3072 ./tools/build/build.sh
```
- or provide full custom Node options (if `--max-old-space-size` is already set there, builder will not append another one):
- или передать полный набор собственных Node-опций (если `--max-old-space-size` там уже задан, builder не добавит второй):
```bash
NODE_OPTIONS="--max-old-space-size=3072" ./tools/build/build.sh
@@ -229,7 +292,7 @@ echo "bun_exit=$?"
rm -rf .toolchain/bun .toolchain/bun-tmp
rm -f .toolchain/downloads/bun-linux-x64-*.zip
rm -f .toolchain/downloads/bun-linux-x64-baseline-*.zip
PACKAGE_VERSION=0.2.1 ./tools/build/build.sh
PACKAGE_VERSION=1.0.0 ./tools/build/build.sh
```
Проверить shell syntax:
+14 -1
View File
@@ -10,6 +10,8 @@ BUILD_DIR="$ROOT_DIR/tools/build"
. "$BUILD_DIR/lib/deps.sh"
# shellcheck source=tools/build/lib/verify.sh
. "$BUILD_DIR/lib/verify.sh"
# shellcheck source=tools/build/lib/hysteria.sh
. "$BUILD_DIR/lib/hysteria.sh"
# shellcheck source=tools/build/lib/acceptance.sh
. "$BUILD_DIR/lib/acceptance.sh"
# shellcheck source=tools/build/lib/package.sh
@@ -24,9 +26,15 @@ main() {
ensure_build_dependencies
ensure_toolchain
PACKAGE_VERSION="${PACKAGE_VERSION:-0.1.0}"
PACKAGE_VERSION="${PACKAGE_VERSION:-1.0.0}"
BUILD_ID="${BUILD_ID:-$(date -u +%Y%m%dT%H%M%SZ)}"
log_step "Checking orchestrator contracts"
run_orchestrator_tests
log_step "Resolving upstream Hysteria and running compatibility gate"
resolve_and_verify_hysteria
log_step "Preparing package stage"
prepare_stage "$PACKAGE_VERSION" "$BUILD_ID"
@@ -36,6 +44,11 @@ main() {
log_step "Bundling HY2XS admin"
bundle_ui
# Тесты админки идут после bundle_ui: `go:embed all:dist` требует уже
# собранных frontend-ассетов, которых нет в git.
log_step "Checking HY2XS admin contracts"
run_admin_tests
log_step "Writing metadata"
write_metadata "$PACKAGE_VERSION" "$BUILD_ID"
+7 -3
View File
@@ -1,3 +1,7 @@
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
# Файл генерируется tools/build/lib/hysteria.sh.
# HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.
HYSTERIA_VERSION=v2.12.2
HYSTERIA_ARTIFACT_URL=https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hysteria-linux-amd64
HYSTERIA_ARTIFACT_SHA256=6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94
HYSTERIA_RESOLUTION=latest-stable
HYSTERIA_RESOLVED_AT=2026-08-27T00:00:00Z
+81
View File
@@ -30,6 +30,87 @@ run_fix20_acceptance_subset() {
log_step "Acceptance: force password change production default"
grep -q '^HY2XS_FORCE_PASSWORD_CHANGE=false$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FORCE_PASSWORD_CHANGE must default to false"
log_step "Acceptance: config schema version is declared"
grep -q '^HY2XS_CONFIG_SCHEMA_VERSION=2$' "$package_dir/config/hy2xs.env" \
|| fail "acceptance: HY2XS_CONFIG_SCHEMA_VERSION must be 2 in the packaged baseline"
log_step "Acceptance: fresh install defaults to Gecko obfuscation"
grep -q '^HY2XS_HYSTERIA_OBFS_TYPE=gecko$' "$package_dir/config/hy2xs.env" \
|| fail "acceptance: fresh installations must default to HY2XS_HYSTERIA_OBFS_TYPE=gecko"
log_step "Acceptance: obfs type is not hardcoded in the Hysteria template"
grep -q '{{OBFS_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: hysteria template must render the obfs block from the orchestrator"
! grep -Eq '^\s*type:\s*(gecko|salamander)\s*$' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: hysteria template must not hardcode an obfs type"
log_step "Acceptance: modern server baseline is present in the template"
grep -q 'disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: bandwidth.disableLossCompensation missing from hysteria template"
grep -q '{{CONGESTION_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: congestion block missing from hysteria template"
grep -q '{{QUIC_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: quic block missing from hysteria template"
log_step "Acceptance: post-install env derives obfs type from resolved context"
grep -q '^HY2_OBFS_TYPE={{OBFS_TYPE}}$' "$package_dir/templates/env/post-install.env.tpl" \
|| fail "acceptance: post-install env must render the resolved obfs type, not a second set of defaults"
! grep -Eq '^HY2_OBFS_TYPE=(gecko|salamander)$' "$package_dir/templates/env/post-install.env.tpl" \
|| fail "acceptance: post-install env must not hardcode an obfs type"
log_step "Acceptance: production defaults are declared in exactly one module"
grep -q 'DEFAULT_HYSTERIA_OBFS_TYPE' orchestrator/src/config/profile.ts \
|| fail "acceptance: the default obfs type must be declared in orchestrator/src/config/profile.ts"
local default_declarations
default_declarations="$(grep -rl 'DEFAULT_HYSTERIA_OBFS_TYPE\s*[:=]' orchestrator/src \
| grep -v 'orchestrator/src/config/profile.ts' || true)"
[ -z "$default_declarations" ] \
|| fail "acceptance: the default obfs type must not be re-declared in: $default_declarations"
log_step "Acceptance: runtime env is derived from config, not literals"
! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type"
grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config"
! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \
|| fail "acceptance: env.ts must not carry its own obfs fallback default"
log_step "Acceptance: package metadata records how the Hysteria version was chosen"
grep -q '^hysteria_resolution=' "$package_dir/metadata/package.env" \
|| fail "acceptance: metadata must record hysteria_resolution"
grep -q '^hysteria_resolved_at=' "$package_dir/metadata/package.env" \
|| fail "acceptance: metadata must record hysteria_resolved_at"
grep -q '^hysteria_compat_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: release packages must be built with the Hysteria compatibility gate enabled"
grep -Eq '^hysteria_artifact_url=https://github\.com/HyNetworks/hysteria/' "$package_dir/metadata/package.env" \
|| fail "acceptance: Hysteria artifact must come from the canonical HyNetworks upstream"
log_step "Acceptance: install-time never resolves a moving latest"
! grep -rq 'api.github.com' orchestrator/src/commands orchestrator/src/steps \
|| fail "acceptance: install-time code must not query the upstream release API"
! grep -rq 'download.hysteria.network' orchestrator/src \
|| fail "acceptance: install-time code must not use the moving latest download URL"
log_step "Acceptance: smoke verifies config semantics, not substrings"
grep -q 'assertHysteriaConfigMatchesProfile' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke must verify the effective config semantically"
log_step "Acceptance: admin export preserves unknown upstream fields and strips secrets"
grep -q 'ExportHysteria2ConfigYaml' apps/controller/config.go \
|| fail "acceptance: hysteria config export must go through the sanitizing raw-YAML path"
grep -q 'GetRawHysteria2Config' apps/service/hysteria2_export.go \
|| fail "acceptance: export must read the raw YAML instead of the typed model"
log_step "Acceptance: frontend ACME registry matches current upstream"
# Ищем именно предлагаемое значение, а не упоминание в комментарии.
! grep -q '"namedotcom"' apps/frontend/src/views/hysteria/list/index.vue \
|| fail "acceptance: namedotcom was removed upstream in Hysteria 2.11.0 and must not be offered"
local provider
for provider in cloudflare duckdns gandi godaddy namecheap njalla porkbun vultr; do
grep -q "\"${provider}\"" apps/frontend/src/views/hysteria/list/index.vue \
|| fail "acceptance: ACME DNS provider ${provider} is missing from the UI registry"
done
log_step "Acceptance: systemd unit production env"
grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing"
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env bash
set -euo pipefail
# Разрешение upstream-версии Hysteria и compatibility gate.
#
# Ключевой инвариант: "latest" вычисляется на build machine и замораживается
# в metadata пакета. Target-сервер никогда не обращается к moving "latest".
HYSTERIA_LOCK_FILE="tools/build/hysteria-lock.env"
HYSTERIA_WORK_DIR="tools/build/output/hysteria"
# stable — определить последнюю стабильную версию через upstream API (default);
# pinned — использовать закоммиченный tools/build/hysteria-lock.env без сети.
HYSTERIA_CHANNEL="${HYSTERIA_CHANNEL:-stable}"
# Явный пин конкретной версии вида v2.12.2. Побеждает автоматический выбор.
HYSTERIA_VERSION_OVERRIDE="${HYSTERIA_VERSION_OVERRIDE:-}"
# Обновлять ли tools/build/hysteria-lock.env резолвнутыми значениями.
HYSTERIA_WRITE_LOCK="${HYSTERIA_WRITE_LOCK:-false}"
# Compatibility gate обязателен для production-сборки.
HYSTERIA_COMPAT_GATE="${HYSTERIA_COMPAT_GATE:-true}"
HYSTERIA_GATE_PORT="${HYSTERIA_GATE_PORT:-34443}"
HYSTERIA_GATE_STATS_PORT="${HYSTERIA_GATE_STATS_PORT:-34712}"
HYSTERIA_GATE_TIMEOUT_SECONDS="${HYSTERIA_GATE_TIMEOUT_SECONDS:-15}"
validate_hysteria_version_format() {
local value="$1"
printf '%s' "$value" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "invalid Hysteria version format: $value (expected vX.Y.Z)"
}
validate_hysteria_sha256_format() {
local value="$1"
case "$value" in
replace-with-*|"")
fail "HYSTERIA_ARTIFACT_SHA256 must be a real release sha256"
;;
esac
printf '%s' "$value" | grep -Eq '^[a-fA-F0-9]{64}$' \
|| fail "HYSTERIA_ARTIFACT_SHA256 must be a 64-char hex SHA256, got: $value"
}
load_pinned_hysteria_lock() {
[ -f "$HYSTERIA_LOCK_FILE" ] || fail "missing Hysteria lock file: $HYSTERIA_LOCK_FILE"
# shellcheck disable=SC1090
. "$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_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 is required in $HYSTERIA_LOCK_FILE"
validate_hysteria_version_format "$HYSTERIA_VERSION"
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
HYSTERIA_RESOLUTION="pinned"
HYSTERIA_RESOLVED_AT="${HYSTERIA_RESOLVED_AT:-unknown}"
export HYSTERIA_VERSION HYSTERIA_ARTIFACT_URL HYSTERIA_ARTIFACT_SHA256 HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT
}
resolve_hysteria_from_upstream() {
local resolver_args=(--channel stable)
if [ -n "$HYSTERIA_VERSION_OVERRIDE" ]; then
validate_hysteria_version_format "$HYSTERIA_VERSION_OVERRIDE"
resolver_args+=(--version "$HYSTERIA_VERSION_OVERRIDE")
log_info "Resolving pinned Hysteria version override: $HYSTERIA_VERSION_OVERRIDE"
else
log_info "Resolving latest stable Hysteria release from upstream"
fi
local resolver_output
resolver_output="$(cd orchestrator && "$BUN_BIN" run tools/resolve-hysteria.ts "${resolver_args[@]}")" \
|| fail "Hysteria release resolution failed"
local key value line
HYSTERIA_VERSION=""
HYSTERIA_TAG=""
HYSTERIA_ARTIFACT_URL=""
HYSTERIA_PUBLISHED_AT=""
HYSTERIA_RESOLUTION=""
while IFS= read -r line; do
[ -n "$line" ] || continue
key="${line%%=*}"
value="${line#*=}"
case "$key" in
HYSTERIA_VERSION) HYSTERIA_VERSION="$value" ;;
HYSTERIA_TAG) HYSTERIA_TAG="$value" ;;
HYSTERIA_ARTIFACT_URL) HYSTERIA_ARTIFACT_URL="$value" ;;
HYSTERIA_PUBLISHED_AT) HYSTERIA_PUBLISHED_AT="$value" ;;
HYSTERIA_RESOLUTION) HYSTERIA_RESOLUTION="$value" ;;
*) fail "unexpected resolver output line: $line" ;;
esac
done <<<"$resolver_output"
[ -n "$HYSTERIA_VERSION" ] || fail "resolver did not return HYSTERIA_VERSION"
[ -n "$HYSTERIA_ARTIFACT_URL" ] || fail "resolver did not return HYSTERIA_ARTIFACT_URL"
validate_hysteria_version_format "$HYSTERIA_VERSION"
HYSTERIA_RESOLVED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
log_info "Resolved Hysteria $HYSTERIA_VERSION (tag ${HYSTERIA_TAG:-unknown}, published ${HYSTERIA_PUBLISHED_AT:-unknown})"
export HYSTERIA_VERSION HYSTERIA_TAG HYSTERIA_ARTIFACT_URL HYSTERIA_PUBLISHED_AT
export HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT
}
download_hysteria_artifact() {
mkdir -p "$HYSTERIA_WORK_DIR"
HYSTERIA_BINARY="$HYSTERIA_WORK_DIR/hysteria-linux-amd64"
log_info "Downloading $HYSTERIA_ARTIFACT_URL"
rm -f "$HYSTERIA_BINARY"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \
"$HYSTERIA_ARTIFACT_URL" -o "$HYSTERIA_BINARY" \
|| fail "failed to download Hysteria artifact: $HYSTERIA_ARTIFACT_URL"
[ -s "$HYSTERIA_BINARY" ] || fail "downloaded Hysteria artifact is empty"
local actual_sha
actual_sha="$(sha256sum "$HYSTERIA_BINARY" | awk '{print $1}')"
if [ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ]; then
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
[ "$actual_sha" = "$HYSTERIA_ARTIFACT_SHA256" ] \
|| fail "Hysteria artifact sha256 mismatch: expected $HYSTERIA_ARTIFACT_SHA256, got $actual_sha"
else
HYSTERIA_ARTIFACT_SHA256="$actual_sha"
log_info "Computed Hysteria artifact sha256: $HYSTERIA_ARTIFACT_SHA256"
fi
export HYSTERIA_ARTIFACT_SHA256 HYSTERIA_BINARY
chmod 0755 "$HYSTERIA_BINARY"
local reported_version
reported_version="$("$HYSTERIA_BINARY" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
[ -n "$reported_version" ] || fail "unable to read version from downloaded Hysteria binary"
[ "$reported_version" = "$HYSTERIA_VERSION" ] \
|| fail "downloaded Hysteria binary reports $reported_version, expected $HYSTERIA_VERSION"
log_info "Verified Hysteria binary: $HYSTERIA_VERSION ($HYSTERIA_ARTIFACT_SHA256)"
}
assert_gate_port_is_free() {
local port="$1"
local proto_flag="$2"
if command -v ss >/dev/null 2>&1; then
if ss -H "$proto_flag" 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
fail "compatibility gate port $port is already in use; override HYSTERIA_GATE_PORT/HYSTERIA_GATE_STATS_PORT"
fi
fi
}
gate_generate_self_signed_cert() {
local dir="$1"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj /CN=hy2xs-compat-gate \
-keyout "$dir/gate.key" -out "$dir/gate.crt" >/dev/null 2>&1 \
|| fail "compatibility gate could not generate a self-signed certificate"
}
gate_run_single_profile() {
local obfs_type="$1"
local gate_dir="$2"
local config_path="$gate_dir/config-$obfs_type.yaml"
local log_path="$gate_dir/hysteria-$obfs_type.log"
log_info "Compatibility gate: rendering canonical config (obfs=$obfs_type)"
(
cd orchestrator
"$BUN_BIN" run tools/render-canonical-config.ts \
--package-dir ../package \
--obfs "$obfs_type" \
--tls-mode file \
--cert "$gate_dir/gate.crt" \
--key "$gate_dir/gate.key" \
--port "$HYSTERIA_GATE_PORT" \
--traffic-stats-port "$HYSTERIA_GATE_STATS_PORT" \
--out "$config_path"
) || fail "BUILD FAILED: HY2XS could not render canonical config for obfs=$obfs_type"
grep -q "type: $obfs_type" "$config_path" \
|| fail "compatibility gate rendered a config without obfs type $obfs_type"
log_info "Compatibility gate: starting Hysteria $HYSTERIA_VERSION with generated config (obfs=$obfs_type)"
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BINARY" server -c "$config_path" >"$log_path" 2>&1 &
local gate_pid=$!
local ready="false"
local waited=0
while [ "$waited" -lt "$HYSTERIA_GATE_TIMEOUT_SECONDS" ]; do
if ! kill -0 "$gate_pid" 2>/dev/null; then
break
fi
if command -v ss >/dev/null 2>&1 \
&& ss -H -lun 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${HYSTERIA_GATE_PORT}$"; then
ready="true"
break
fi
sleep 1
waited=$((waited + 1))
done
if [ "$ready" != "true" ]; then
kill "$gate_pid" 2>/dev/null || true
wait "$gate_pid" 2>/dev/null || true
log_info "--- Hysteria compatibility gate output (obfs=$obfs_type) ---"
cat "$log_path" >&2 || true
fail "BUILD FAILED: unsupported Hysteria stable $HYSTERIA_VERSION (obfs=$obfs_type config was rejected)"
fi
kill "$gate_pid" 2>/dev/null || true
wait "$gate_pid" 2>/dev/null || true
log_info "Compatibility gate passed for obfs=$obfs_type"
}
run_hysteria_compatibility_gate() {
if [ "$HYSTERIA_COMPAT_GATE" != "true" ]; then
log_info "WARNING: Hysteria compatibility gate is disabled (HYSTERIA_COMPAT_GATE=$HYSTERIA_COMPAT_GATE)"
return 0
fi
require_tool openssl
[ -x "${HYSTERIA_BINARY:-}" ] || fail "compatibility gate requires a verified Hysteria binary"
assert_gate_port_is_free "$HYSTERIA_GATE_PORT" "-lun"
assert_gate_port_is_free "$HYSTERIA_GATE_STATS_PORT" "-ltn"
local gate_dir
gate_dir="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '$gate_dir'" RETURN
gate_generate_self_signed_cert "$gate_dir"
gate_run_single_profile "gecko" "$gate_dir"
gate_run_single_profile "salamander" "$gate_dir"
log_info "Compatibility gate: Hysteria $HYSTERIA_VERSION accepts the HY2XS production config"
}
write_hysteria_lock_file() {
if [ "$HYSTERIA_WRITE_LOCK" != "true" ]; then
return 0
fi
log_info "Updating $HYSTERIA_LOCK_FILE with resolved values"
cat >"$HYSTERIA_LOCK_FILE" <<EOF
# Файл генерируется tools/build/lib/hysteria.sh.
# HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.
HYSTERIA_VERSION=$HYSTERIA_VERSION
HYSTERIA_ARTIFACT_URL=$HYSTERIA_ARTIFACT_URL
HYSTERIA_ARTIFACT_SHA256=$HYSTERIA_ARTIFACT_SHA256
HYSTERIA_RESOLUTION=$HYSTERIA_RESOLUTION
HYSTERIA_RESOLVED_AT=$HYSTERIA_RESOLVED_AT
EOF
}
resolve_and_verify_hysteria() {
case "$HYSTERIA_CHANNEL" in
stable)
resolve_hysteria_from_upstream
# sha256 считается локально от скачанного артефакта
HYSTERIA_ARTIFACT_SHA256=""
;;
pinned)
log_info "Using pinned Hysteria lock file (offline): $HYSTERIA_LOCK_FILE"
load_pinned_hysteria_lock
;;
*)
fail "unsupported HYSTERIA_CHANNEL: $HYSTERIA_CHANNEL (use stable or pinned)"
;;
esac
download_hysteria_artifact
run_hysteria_compatibility_gate
write_hysteria_lock_file
}
+44 -14
View File
@@ -3,7 +3,6 @@ set -euo pipefail
STAGE_DIR="tools/build/output/hy2xs-install"
ADMIN_BUILD_DIR="tools/build/output/hy2xs-admin-build"
HYSTERIA_LOCK_FILE="tools/build/hysteria-lock.env"
prepare_stage() {
local version="$1"
@@ -27,6 +26,38 @@ prepare_stage() {
printf '%s\n' "$build_id" >"$STAGE_DIR/metadata/package.build_id"
}
run_orchestrator_tests() {
if [ "${SKIP_TESTS:-false}" = "true" ]; then
log_info "WARNING: orchestrator tests skipped (SKIP_TESTS=true)"
return 0
fi
(
cd orchestrator
"$BUN_BIN" install --frozen-lockfile
"$BUN_BIN" x tsc --noEmit
"$BUN_BIN" test
) || fail "orchestrator contract tests failed"
}
run_admin_tests() {
if [ "${SKIP_TESTS:-false}" = "true" ]; then
log_info "WARNING: HY2XS admin tests skipped (SKIP_TESTS=true)"
return 0
fi
# `go:embed all:dist` требует собранных frontend-ассетов, поэтому эта
# функция должна вызываться только после bundle_ui.
[ -d "${UI_SRC:-apps}/frontend/dist" ] \
|| fail "run_admin_tests must run after bundle_ui: ${UI_SRC:-apps}/frontend/dist is missing"
(
cd "${UI_SRC:-apps}"
GOTOOLCHAIN=local "$GO_BIN" vet ./...
GOTOOLCHAIN=local "$GO_BIN" test ./...
) || fail "HY2XS admin contract tests failed"
}
build_orchestrator() {
local bun_compile_target="${BUN_COMPILE_TARGET:-bun-linux-x64}"
@@ -88,19 +119,13 @@ write_metadata() {
dirty_tree="true"
fi
[ -f "$HYSTERIA_LOCK_FILE" ] || fail "missing Hysteria lock file: $HYSTERIA_LOCK_FILE"
# shellcheck disable=SC1090
. "$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_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"
# Значения приходят из resolve_and_verify_hysteria: они уже проверены
# по SHA-256, по `hysteria version` и по compatibility gate.
[ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION was not resolved before write_metadata"
[ -n "${HYSTERIA_ARTIFACT_URL:-}" ] || fail "HYSTERIA_ARTIFACT_URL was not resolved before write_metadata"
[ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 was not resolved before write_metadata"
validate_hysteria_version_format "$HYSTERIA_VERSION"
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
{
printf 'name=HY2XS\n'
@@ -127,11 +152,16 @@ write_metadata() {
printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION"
printf 'hysteria_artifact_url=%s\n' "$HYSTERIA_ARTIFACT_URL"
printf 'hysteria_artifact_sha256=%s\n' "$HYSTERIA_ARTIFACT_SHA256"
printf 'hysteria_channel=%s\n' "${HYSTERIA_CHANNEL:-unknown}"
printf 'hysteria_resolution=%s\n' "${HYSTERIA_RESOLUTION:-unknown}"
printf 'hysteria_resolved_at=%s\n' "${HYSTERIA_RESOLVED_AT:-unknown}"
printf 'hysteria_compat_gate=%s\n' "${HYSTERIA_COMPAT_GATE:-unknown}"
} >"$STAGE_DIR/metadata/package.env"
printf '%s\n' "$HYSTERIA_VERSION" >"$STAGE_DIR/metadata/hysteria.version"
printf '%s\n' "$HYSTERIA_ARTIFACT_URL" >"$STAGE_DIR/metadata/hysteria.url"
printf '%s\n' "$HYSTERIA_ARTIFACT_SHA256" >"$STAGE_DIR/metadata/hysteria.sha256"
printf '%s\n' "${HYSTERIA_RESOLUTION:-unknown}" >"$STAGE_DIR/metadata/hysteria.resolution"
(
cd "$STAGE_DIR"
+27
View File
@@ -54,6 +54,10 @@ verify_archive() {
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/metadata/checksums.txt$' || fail "archive missing checksums"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.version$' || fail "archive missing pinned Hysteria version"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.url$' || fail "archive missing pinned Hysteria artifact url"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.sha256$' || fail "archive missing pinned Hysteria sha256"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.resolution$' || fail "archive missing Hysteria resolution marker"
local license_text
license_text="$(tar -xOzf "$archive" hy2xs-install/LICENSE)"
@@ -70,6 +74,29 @@ verify_archive() {
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"
printf '%s\n' "$hysteria_tpl" | grep -q '{{OBFS_BLOCK}}' || fail "hysteria template must render a typed obfs block"
# Замороженная версия обязана совпадать во всех местах пакета.
local pinned_version pinned_sha pinned_url meta_env
pinned_version="$(tar -xOzf "$archive" hy2xs-install/metadata/hysteria.version | tr -d '\n')"
pinned_sha="$(tar -xOzf "$archive" hy2xs-install/metadata/hysteria.sha256 | tr -d '\n')"
pinned_url="$(tar -xOzf "$archive" hy2xs-install/metadata/hysteria.url | tr -d '\n')"
meta_env="$(tar -xOzf "$archive" hy2xs-install/metadata/package.env)"
printf '%s' "$pinned_version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "packaged Hysteria version is not a concrete vX.Y.Z: $pinned_version"
printf '%s' "$pinned_sha" | grep -Eq '^[a-f0-9]{64}$' \
|| fail "packaged Hysteria sha256 is not a 64-char hex digest"
printf '%s' "$pinned_url" | grep -Fq "$pinned_version" \
|| fail "packaged Hysteria artifact url does not point at the pinned version: $pinned_url"
if printf '%s' "$pinned_url" | grep -q 'latest'; then
fail "packaged Hysteria artifact url must be immutable, not a moving latest: $pinned_url"
fi
printf '%s\n' "$meta_env" | grep -q "^hysteria_version=${pinned_version}$" \
|| fail "metadata package.env disagrees with metadata/hysteria.version"
printf '%s\n' "$meta_env" | grep -q "^hysteria_artifact_sha256=${pinned_sha}$" \
|| fail "metadata package.env disagrees with metadata/hysteria.sha256"
local post_install_tpl
post_install_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/env/post-install.env.tpl)"
+527
View File
@@ -0,0 +1,527 @@
#!/usr/bin/env bash
set -euo pipefail
# HY2XS end-to-end проверка связки «сервер + реальный клиент Hysteria».
#
# Что проверяется:
# 1. сервер принимает сгенерированный HY2XS конфиг и стартует;
# 2. TLS handshake;
# 3. handshake с обфускацией (gecko и salamander);
# 4. HTTP auth HY2XS: допуск разрешённого пира и отказ неразрешённому;
# 5. клиент подключается ИМЕННО по сгенерированному hysteria2:// URI;
# 6. TCP forwarding через туннель;
# 7. UDP forwarding через туннель;
# 8. trafficStats API: валидный и невалидный secret;
# 9. per-peer accounting;
# 10. перезапуск сервера и быстрое переподключение клиента.
#
# Скрипт самодостаточен: поднимает mock HY2XS auth endpoint, поэтому не требует
# установленной админки и базы. Рассчитан на Debian 13 amd64.
#
# ./tools/test/e2e-hysteria.sh
# HYSTERIA_BIN=/tmp/hysteria ./tools/test/e2e-hysteria.sh --obfs salamander
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
OBFS_TYPES=()
HYSTERIA_BIN="${HYSTERIA_BIN:-/usr/local/bin/hysteria}"
BUN_BIN="${BUN_BIN:-bun}"
SERVER_PORT="${E2E_SERVER_PORT:-34643}"
STATS_PORT="${E2E_STATS_PORT:-34644}"
AUTH_PORT="${E2E_AUTH_PORT:-34645}"
ORIGIN_TCP_PORT="${E2E_ORIGIN_TCP_PORT:-34646}"
ORIGIN_UDP_PORT="${E2E_ORIGIN_UDP_PORT:-34647}"
FORWARD_TCP_PORT="${E2E_FORWARD_TCP_PORT:-34648}"
FORWARD_UDP_PORT="${E2E_FORWARD_UDP_PORT:-34649}"
VALID_SECRET="hy2xs-e2e-valid-peer-secret"
INVALID_SECRET="hy2xs-e2e-invalid-peer-secret"
STATS_SECRET="hy2xs-e2e-traffic-secret"
OBFS_PASSWORD="hy2xs-e2e-obfs-password"
SNI_NAME="hy2xs-e2e.local"
WORK_DIR=""
SERVER_PID=""
CLIENT_PID=""
AUTH_PID=""
ORIGIN_PID=""
log() { printf '[hy2xs-e2e] %s\n' "$*"; }
step() { printf '\n[hy2xs-e2e] ==> %s\n' "$*"; }
pass() { printf '[hy2xs-e2e] ok: %s\n' "$*"; }
die() { printf '[hy2xs-e2e] FAIL: %s\n' "$*" >&2; exit 1; }
usage() {
cat >&2 <<'EOF'
Usage: tools/test/e2e-hysteria.sh [--obfs gecko|salamander|all]
Environment:
HYSTERIA_BIN path to the Hysteria binary under test (default /usr/local/bin/hysteria)
BUN_BIN path to bun (default: bun from PATH)
EOF
exit 2
}
parse_args() {
local requested="all"
while [ $# -gt 0 ]; do
case "$1" in
--obfs)
requested="${2:-}"
shift 2 || true
;;
-h|--help) usage ;;
*) die "unknown argument: $1" ;;
esac
done
case "$requested" in
all) OBFS_TYPES=(gecko salamander) ;;
gecko) OBFS_TYPES=(gecko) ;;
salamander) OBFS_TYPES=(salamander) ;;
*) die "unsupported --obfs value: $requested" ;;
esac
}
require_tool() {
command -v "$1" >/dev/null 2>&1 || die "required tool not found: $1"
}
preflight() {
[ -x "$HYSTERIA_BIN" ] || die "Hysteria binary not found or not executable: $HYSTERIA_BIN (set HYSTERIA_BIN)"
require_tool openssl
require_tool curl
require_tool ss
command -v "$BUN_BIN" >/dev/null 2>&1 || die "bun not found (set BUN_BIN)"
local port
for port in "$SERVER_PORT" "$STATS_PORT" "$AUTH_PORT" \
"$ORIGIN_TCP_PORT" "$ORIGIN_UDP_PORT" "$FORWARD_TCP_PORT" "$FORWARD_UDP_PORT"; do
if ss -H -lantu 2>/dev/null | awk '{print $5}' | grep -Eq "[:.]${port}\$"; then
die "port $port is already in use"
fi
done
log "Hysteria under test: $("$HYSTERIA_BIN" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
}
kill_pid() {
local name="$1"
local pid="${!name}"
[ -n "$pid" ] || return 0
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
printf -v "$name" '%s' ""
}
cleanup() {
kill_pid CLIENT_PID
kill_pid SERVER_PID
kill_pid ORIGIN_PID
kill_pid AUTH_PID
[ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR" || true
}
trap cleanup EXIT
wait_for_port() {
local port="$1" proto_flag="$2" label="$3" attempts="${4:-30}"
local i=0
while [ "$i" -lt "$attempts" ]; do
if ss -H "$proto_flag" 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}\$"; then
return 0
fi
sleep 1
i=$((i + 1))
done
die "$label did not start listening on port $port"
}
# Mock HY2XS auth endpoint повторяет контракт /hui/hysteria2/auth:
# проверку machine access_token и отказ неразрешённому секрету.
start_auth_endpoint() {
cat >"$WORK_DIR/auth-server.ts" <<EOF
const validSecret = ${VALID_SECRET@Q};
const machineToken = ${STATS_SECRET@Q};
Bun.serve({
hostname: "127.0.0.1",
port: ${AUTH_PORT},
async fetch(request) {
const url = new URL(request.url);
if (url.searchParams.get("access_token") !== machineToken) {
return new Response("forbidden", { status: 403 });
}
const body = await request.json().catch(() => null);
if (!body || typeof body.auth !== "string" || typeof body.tx !== "number") {
return Response.json({ ok: false }, { status: 400 });
}
if (body.auth !== validSecret) {
return Response.json({ ok: false });
}
return Response.json({ ok: true, id: "e2e-peer" });
}
});
EOF
"$BUN_BIN" run "$WORK_DIR/auth-server.ts" >"$WORK_DIR/auth.log" 2>&1 &
AUTH_PID=$!
wait_for_port "$AUTH_PORT" "-ltn" "mock auth endpoint"
pass "mock HY2XS auth endpoint is up"
}
# TCP- и UDP-origin: отвечают фиксированной строкой, чтобы можно было
# однозначно отличить рабочий туннель от неответа.
start_origins() {
cat >"$WORK_DIR/origins.ts" <<EOF
Bun.listen({
hostname: "127.0.0.1",
port: ${ORIGIN_TCP_PORT},
socket: {
open(socket) {
socket.write("HY2XS-TCP-OK\n");
socket.flush();
socket.end();
},
data() {},
error() {}
}
});
await Bun.udpSocket({
hostname: "127.0.0.1",
port: ${ORIGIN_UDP_PORT},
socket: {
data(socket, _buf, port, address) {
socket.send("HY2XS-UDP-OK", port, address);
}
}
});
EOF
"$BUN_BIN" run "$WORK_DIR/origins.ts" >"$WORK_DIR/origins.log" 2>&1 &
ORIGIN_PID=$!
wait_for_port "$ORIGIN_TCP_PORT" "-ltn" "tcp origin"
wait_for_port "$ORIGIN_UDP_PORT" "-lun" "udp origin"
pass "TCP and UDP origins are up"
}
render_server_config() {
local obfs_type="$1"
(
cd orchestrator
"$BUN_BIN" run tools/render-canonical-config.ts \
--package-dir ../package \
--obfs "$obfs_type" \
--tls-mode file \
--cert "$WORK_DIR/server.crt" \
--key "$WORK_DIR/server.key" \
--port "$SERVER_PORT" \
--traffic-stats-port "$STATS_PORT" \
--out "$WORK_DIR/server.yaml"
) || die "could not render canonical server config for obfs=$obfs_type"
# Подставляем e2e-секреты и локальный auth endpoint, не трогая структуру
# конфига: проверяется тот же YAML, который получает production.
"$BUN_BIN" -e '
const [path, obfs, stats, authPort] = process.argv.slice(1);
let text = require("node:fs").readFileSync(path, "utf8");
text = text.split("hy2xs-compat-gate-obfs-password").join(obfs);
text = text.split("hy2xs-compat-gate-traffic-secret").join(stats);
text = text.replace(/url: http:\/\/127\.0\.0\.1:\d+\//, `url: http://127.0.0.1:${authPort}/`);
require("node:fs").writeFileSync(path, text);
' "$WORK_DIR/server.yaml" "$OBFS_PASSWORD" "$STATS_SECRET" "$AUTH_PORT" \
|| die "could not inject e2e secrets into the server config"
grep -q "type: $obfs_type" "$WORK_DIR/server.yaml" \
|| die "rendered server config does not use obfs type $obfs_type"
grep -q "127.0.0.1:${AUTH_PORT}/hui/hysteria2/auth?access_token=" "$WORK_DIR/server.yaml" \
|| die "server config lost the HY2XS machine auth token"
}
start_server() {
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" server -c "$WORK_DIR/server.yaml" \
>>"$WORK_DIR/server.log" 2>&1 &
SERVER_PID=$!
local i=0
while [ "$i" -lt 20 ]; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
log "--- server log ---"
cat "$WORK_DIR/server.log" >&2 || true
die "server exited while starting"
fi
if ss -H -lun 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${SERVER_PORT}\$"; then
return 0
fi
sleep 1
i=$((i + 1))
done
log "--- server log ---"
cat "$WORK_DIR/server.log" >&2 || true
die "server did not start listening on UDP $SERVER_PORT"
}
urlencode() {
"$BUN_BIN" -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1"
}
# Ссылка строится ровно по официальной URI-схеме, как её генерирует админка.
build_share_uri() {
local obfs_type="$1"
local secret="$2"
printf 'hysteria2://%s@127.0.0.1:%s/?obfs=%s&obfs-password=%s&sni=%s&insecure=1' \
"$(urlencode "$secret")" "$SERVER_PORT" "$obfs_type" "$(urlencode "$OBFS_PASSWORD")" "$SNI_NAME"
}
# Клиент конфигурируется ИЗ share URI, а не собирается независимо: именно это
# ловит расхождение между генератором ссылок и реальным сервером.
write_client_config_from_uri() {
cat >"$WORK_DIR/client.yaml" <<EOF
server: $1
tcpForwarding:
- listen: 127.0.0.1:${FORWARD_TCP_PORT}
remote: 127.0.0.1:${ORIGIN_TCP_PORT}
udpForwarding:
- listen: 127.0.0.1:${FORWARD_UDP_PORT}
remote: 127.0.0.1:${ORIGIN_UDP_PORT}
timeout: 20s
EOF
}
start_client() {
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" client -c "$WORK_DIR/client.yaml" \
>>"$WORK_DIR/client.log" 2>&1 &
CLIENT_PID=$!
local i=0
while [ "$i" -lt 25 ]; do
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
log "--- client log ---"
cat "$WORK_DIR/client.log" >&2 || true
die "client exited while connecting via the generated share URI"
fi
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${FORWARD_TCP_PORT}\$"; then
return 0
fi
sleep 1
i=$((i + 1))
done
log "--- client log ---"
cat "$WORK_DIR/client.log" >&2 || true
die "client did not open its forwarding listeners"
}
probe_tcp() {
"$BUN_BIN" -e '
const port = Number(process.argv[1]);
const chunks: string[] = [];
const socket = await Bun.connect({
hostname: "127.0.0.1",
port,
socket: {
data(_s, chunk) { chunks.push(new TextDecoder().decode(chunk)); },
error() {}
}
});
await Bun.sleep(Number(process.argv[2]));
socket.end();
process.stdout.write(chunks.join(""));
' "$FORWARD_TCP_PORT" "3000" 2>/dev/null || true
}
probe_udp() {
"$BUN_BIN" -e '
const port = Number(process.argv[1]);
const answer = await new Promise<string>(async (resolve) => {
const timer = setTimeout(() => resolve(""), Number(process.argv[2]));
const socket = await Bun.udpSocket({
hostname: "127.0.0.1",
port: 0,
socket: {
data(_s, buf) {
clearTimeout(timer);
resolve(new TextDecoder().decode(buf));
}
}
});
socket.send("ping", port, "127.0.0.1");
});
process.stdout.write(answer);
' "$FORWARD_UDP_PORT" "5000" 2>/dev/null || true
}
assert_tcp_forwarding() {
local i=0
while [ "$i" -lt 10 ]; do
case "$(probe_tcp)" in
*HY2XS-TCP-OK*)
pass "TCP forwarding works through the tunnel"
return 0
;;
esac
sleep 1
i=$((i + 1))
done
die "TCP forwarding through the tunnel failed"
}
assert_udp_forwarding() {
local i=0
while [ "$i" -lt 10 ]; do
case "$(probe_udp)" in
*HY2XS-UDP-OK*)
pass "UDP forwarding works through the tunnel"
return 0
;;
esac
sleep 1
i=$((i + 1))
done
die "UDP forwarding through the tunnel failed"
}
assert_traffic_stats() {
local code
code="$(curl --silent --max-time 5 -o "$WORK_DIR/online.json" -w '%{http_code}' \
-H "Authorization: ${STATS_SECRET}" "http://127.0.0.1:${STATS_PORT}/online" || true)"
[ "$code" = "200" ] || die "trafficStats /online returned $code for a valid secret"
local denied
denied="$(curl --silent --max-time 5 -o /dev/null -w '%{http_code}' \
-H "Authorization: definitely-not-the-secret" "http://127.0.0.1:${STATS_PORT}/online" || true)"
case "$denied" in
401|403) ;;
*) die "trafficStats /online returned $denied for an invalid secret, expected 401/403" ;;
esac
local traffic_code
traffic_code="$(curl --silent --max-time 5 -o "$WORK_DIR/traffic.json" -w '%{http_code}' \
-H "Authorization: ${STATS_SECRET}" "http://127.0.0.1:${STATS_PORT}/traffic" || true)"
[ "$traffic_code" = "200" ] || die "trafficStats /traffic returned $traffic_code"
grep -q 'e2e-peer' "$WORK_DIR/traffic.json" \
|| die "per-peer accounting does not contain the authenticated peer: $(cat "$WORK_DIR/traffic.json")"
pass "trafficStats API and per-peer accounting work"
}
assert_reconnect_after_restart() {
kill_pid SERVER_PID
start_server
local i=0
while [ "$i" -lt 25 ]; do
case "$(probe_tcp)" in
*HY2XS-TCP-OK*)
pass "client recovered the tunnel after a server restart (${i}s)"
return 0
;;
esac
sleep 1
i=$((i + 1))
done
die "client did not recover the tunnel after a server restart"
}
assert_auth_rejects_unknown_peer() {
local obfs_type="$1"
kill_pid CLIENT_PID
write_client_config_from_uri "$(build_share_uri "$obfs_type" "$INVALID_SECRET")"
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" client -c "$WORK_DIR/client.yaml" \
>"$WORK_DIR/client-denied.log" 2>&1 &
CLIENT_PID=$!
# Клиент либо завершается, либо не может пробросить трафик.
local i=0
while [ "$i" -lt 15 ]; do
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
kill_pid CLIENT_PID
pass "unauthorized peer is rejected by HY2XS HTTP auth (client exited)"
return 0
fi
if grep -Eqi 'auth|denied|reject|unauthor' "$WORK_DIR/client-denied.log"; then
kill_pid CLIENT_PID
pass "unauthorized peer is rejected by HY2XS HTTP auth (auth failure reported)"
return 0
fi
sleep 1
i=$((i + 1))
done
local leaked
leaked="$(probe_tcp)"
kill_pid CLIENT_PID
case "$leaked" in
*HY2XS-TCP-OK*)
die "server accepted a peer that HY2XS auth must reject"
;;
esac
pass "unauthorized peer cannot pass traffic"
}
run_profile() {
local obfs_type="$1"
step "profile: obfs=$obfs_type"
: >"$WORK_DIR/server.log"
: >"$WORK_DIR/client.log"
render_server_config "$obfs_type"
start_server
pass "server accepted the HY2XS config and started (TLS + $obfs_type)"
local uri
uri="$(build_share_uri "$obfs_type" "$VALID_SECRET")"
log "share URI under test: ${uri//$OBFS_PASSWORD/<obfs-password>}"
write_client_config_from_uri "$uri"
start_client
pass "client connected using the generated hysteria2:// URI (TLS + $obfs_type handshake)"
assert_tcp_forwarding
assert_udp_forwarding
assert_traffic_stats
assert_reconnect_after_restart
assert_auth_rejects_unknown_peer "$obfs_type"
kill_pid CLIENT_PID
kill_pid SERVER_PID
pass "profile obfs=$obfs_type passed"
}
main() {
parse_args "$@"
preflight
WORK_DIR="$(mktemp -d)"
log "work dir: $WORK_DIR"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj "/CN=${SNI_NAME}" \
-addext "subjectAltName=DNS:${SNI_NAME}" \
-keyout "$WORK_DIR/server.key" -out "$WORK_DIR/server.crt" >/dev/null 2>&1 \
|| die "could not generate the e2e certificate"
start_auth_endpoint
start_origins
local obfs_type
for obfs_type in "${OBFS_TYPES[@]}"; do
run_profile "$obfs_type"
done
step "all HY2XS end-to-end checks passed"
}
main "$@"