From ddf0ddf71e21da8997e4626205934f6378e0e1d5 Mon Sep 17 00:00:00 2001
From: Crimson
Date: Thu, 27 Aug 2026 08:15:02 +0500
Subject: [PATCH] =?UTF-8?q?feat(v1):=20Gecko-=D0=BE=D0=B1=D1=84=D1=83?=
=?UTF-8?q?=D1=81=D0=BA=D0=B0=D1=86=D0=B8=D1=8F,=20latest-stable=20Hysteri?=
=?UTF-8?q?a=20=D0=BD=D0=B0=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B5=20?=
=?UTF-8?q?=D0=B8=20forward-compatible=20admin?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Сквозная миграция 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 файлов),
документация на русском.
---
.gitattributes | 23 +
CHANGELOG.md | 129 +++++
README.md | 119 +++-
apps/controller/config.go | 65 +--
apps/frontend/src/api/config/types.ts | 63 ++-
apps/frontend/src/lang/package/en.ts | 29 +-
apps/frontend/src/lang/package/ru.ts | 34 +-
.../src/views/hysteria/list/index.vue | 177 +++++-
apps/frontend/src/views/peer/list/index.vue | 54 +-
apps/model/bo/hysteria2.go | 115 +++-
apps/service/hysteria2_api.go | 55 +-
apps/service/hysteria2_api_test.go | 305 +++++++++-
apps/service/hysteria2_export.go | 208 +++++++
apps/service/hysteria2_export_test.go | 247 ++++++++
docs/01-architecture-baseline.md | 5 +-
docs/02-build-layer-and-package.md | 94 +++-
docs/03-server-hysteria2.md | 164 +++++-
docs/04-admin-panel.md | 55 +-
docs/06-speed-limits-and-congestion.md | 45 +-
docs/09-post-install-env.md | 12 +-
docs/11-testing-and-acceptance.md | 198 +++++++
docs/README.md | 9 +-
orchestrator/package.json | 1 +
orchestrator/src/build/hysteriaRelease.ts | 179 ++++++
orchestrator/src/commands/doctor.ts | 3 +-
orchestrator/src/commands/install.ts | 1 +
orchestrator/src/commands/reconfigure.ts | 3 +-
orchestrator/src/config/env.ts | 44 +-
orchestrator/src/config/profile.ts | 138 +++++
orchestrator/src/steps/config.ts | 30 +-
orchestrator/src/steps/configAssertions.ts | 151 +++++
orchestrator/src/steps/env.ts | 15 +
orchestrator/src/steps/smoke.ts | 42 +-
orchestrator/src/types/context.ts | 9 +-
orchestrator/test/config-assertions.test.ts | 124 +++++
orchestrator/test/env.test.ts | 160 ++++++
orchestrator/test/fixtures.ts | 102 ++++
orchestrator/test/hysteria-release.test.ts | 220 ++++++++
orchestrator/test/release-rollover.test.ts | 91 +++
orchestrator/test/render-config.test.ts | 141 +++++
orchestrator/tools/render-canonical-config.ts | 171 ++++++
orchestrator/tools/resolve-hysteria.ts | 124 +++++
package/config/hy2xs.env | 9 +-
package/templates/env/post-install.env.tpl | 10 +-
package/templates/hysteria/config.yaml.tpl | 14 +-
tools/build/README.md | 131 +++--
tools/build/build.sh | 15 +-
tools/build/hysteria-lock.env | 10 +-
tools/build/lib/acceptance.sh | 81 +++
tools/build/lib/hysteria.sh | 282 ++++++++++
tools/build/lib/package.sh | 58 +-
tools/build/lib/verify.sh | 27 +
tools/test/e2e-hysteria.sh | 527 ++++++++++++++++++
53 files changed, 4827 insertions(+), 291 deletions(-)
create mode 100644 .gitattributes
create mode 100644 CHANGELOG.md
create mode 100644 apps/service/hysteria2_export.go
create mode 100644 apps/service/hysteria2_export_test.go
create mode 100644 orchestrator/src/build/hysteriaRelease.ts
create mode 100644 orchestrator/src/config/profile.ts
create mode 100644 orchestrator/src/steps/configAssertions.ts
create mode 100644 orchestrator/test/config-assertions.test.ts
create mode 100644 orchestrator/test/env.test.ts
create mode 100644 orchestrator/test/fixtures.ts
create mode 100644 orchestrator/test/hysteria-release.test.ts
create mode 100644 orchestrator/test/release-rollover.test.ts
create mode 100644 orchestrator/test/render-config.test.ts
create mode 100644 orchestrator/tools/render-canonical-config.ts
create mode 100644 orchestrator/tools/resolve-hysteria.ts
create mode 100644 tools/build/lib/hysteria.sh
create mode 100755 tools/test/e2e-hysteria.sh
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..8304773
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,23 @@
+# Разработка ведётся в том числе на Windows, где часто включён core.autocrlf=true.
+# Всё, что исполняется или читается на target-сервере Debian, обязано остаться LF:
+# CRLF в shell-скрипте даёт `$'\r': command not found`, а в systemd unit и env-файле
+# ломает разбор значений.
+
+* text=auto eol=lf
+
+*.sh text eol=lf
+*.tpl text eol=lf
+*.env text eol=lf
+*.service text eol=lf
+*.nft text eol=lf
+*.yaml text eol=lf
+*.yml text eol=lf
+*.sql text eol=lf
+
+# Бинарные ассеты не должны трогаться нормализацией.
+*.png binary
+*.ico binary
+*.gif binary
+*.jpg binary
+*.woff binary
+*.woff2 binary
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..3554c11
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,129 @@
+# Изменения HY2XS
+
+Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
+Проект использует [семантическое версионирование](https://semver.org/lang/ru/).
+
+Версия относится к самому HY2XS, а не к Hysteria: версия Hysteria выбирается
+на этапе сборки пакета и фиксируется в его metadata.
+
+## [Unreleased]
+
+## [1.0.0] — 2026-08-27
+
+Первый релиз линейки `v1`.
+
+> **Обновление с `0.x` не поддерживается.**
+> Между `0.x` и `1.0.0` изменились схема конфигурации, тип обфускации
+> по умолчанию и контракт выбора версии Hysteria. Сервер, установленный
+> из пакета `0.x`, нужно поднимать заново: очистка и установка с нуля.
+> Подробности — в разделе «Миграция с 0.x» ниже.
+
+### Добавлено
+
+- **Разрешение версии Hysteria на этапе сборки.** Builder по умолчанию сам
+ определяет последний стабильный upstream-релиз, скачивает артефакт,
+ вычисляет SHA-256 и замораживает `version` + `url` + `sha256` в metadata
+ пакета. Target-сервер по-прежнему скачивает конкретный неизменяемый
+ артефакт и никогда не обращается к moving `latest`.
+- **Compatibility gate в сборке.** До создания release-пакета builder
+ рендерит канонический конфиг HY2XS тем же кодом, что и оркестратор, и
+ запускает с ним реальный бинарник Hysteria — для обоих профилей
+ обфускации. Несовместимый upstream ломает сборку, а не сервер оператора.
+- **Поддержка Gecko-обфускации** (Hysteria 2.9.2+) со сквозной интеграцией:
+ оркестратор, шаблон конфига, модель админки, генерация `hysteria2://` URI,
+ типы и формы фронтенда.
+- **Версия схемы конфигурации** `HY2XS_CONFIG_SCHEMA_VERSION=2`. Пакет
+ отказывается работать с конфигурацией неизвестной схемы вместо того,
+ чтобы молча применить чужие значения.
+- **Современный серверный baseline** в генерируемом конфиге:
+ `congestion.type: bbr` + `bbrProfile: standard`,
+ `bandwidth.disableLossCompensation: false`,
+ `quic.disableStatelessReset: false`, а также
+ `maxIdleTimeout`, `maxIncomingStreams`, `disablePathMTUDiscovery`.
+- **Модель современной схемы Hysteria в админке**: `obfs.gecko`, `ech`,
+ `congestion`, `mimic`, `realm`, `tls.clientCA`,
+ `quic.disableStatelessReset`, `bandwidth.disableLossCompensation`,
+ `masquerade.proxy.xForwarded`. Поля читаются и отображаются, даже если
+ HY2XS не включает их в default-профиль.
+- **Тесты оркестратора** (`bun test`): разбор env, рендер конфига,
+ семантические инварианты профиля, резолвер upstream-релизов,
+ release rollover.
+- **E2E-проверка с реальным клиентом Hysteria** — `tools/test/e2e-hysteria.sh`:
+ TLS и obfs handshake, HTTP auth (допуск и отказ), TCP и UDP forwarding,
+ trafficStats, per-peer accounting, переподключение после перезапуска
+ сервера и подключение клиента именно по сгенерированной ссылке.
+- **`CHANGELOG.md`** в корне репозитория.
+
+### Изменено
+
+- **Обфускация по умолчанию для новых установок — Gecko.** Salamander
+ остаётся полностью поддержанным режимом совместимости и выбирается через
+ `HY2XS_HYSTERIA_OBFS_TYPE=salamander`.
+- **Gecko использует upstream-defaults `512/1200`** и не выносит размеры
+ пакетов в env: официальная URI-схема не умеет их передавать, поэтому
+ нестандартные значения сделали бы клиентскую ссылку неполной.
+- **Тип обфускации больше не собирается внутри статического YAML.**
+ Оркестратор формирует проверенный `obfs`-блок целиком, поэтому
+ комбинация вида `type: gecko` рядом с блоком `salamander` структурно
+ невозможна.
+- **Экспорт конфига Hysteria работает от исходного YAML**, а не от
+ типизированной модели: поля, о которых HY2XS ещё не знает, переживают
+ выгрузку.
+- **Smoke-проверки разбирают YAML** и сверяют его с production-профилем,
+ вместо поиска подстрок.
+- **Канонический upstream-репозиторий** — `HyNetworks/hysteria`
+ (вместо устаревших ссылок на `apernet`).
+- **Реестр ACME DNS-провайдеров во фронтенде** приведён к актуальному
+ upstream: добавлены `namecheap`, `njalla`, `porkbun`.
+- **Дефолты формы Hysteria во фронтенде** отражают baseline HY2XS
+ (50/50 Mbps, Let's Encrypt, каталог ACME), а не пример из upstream-доки.
+- Сборка запускает тесты оркестратора и админки до упаковки.
+
+### Исправлено
+
+- **`hysteria2://` для Gecko.** Генератор ссылок был завязан на
+ `Obfs.Salamander.Password`, поэтому при любой другой обфускации выдавал
+ формально корректную, но неработающую ссылку без параметров `obfs`.
+- **SNI в клиентской ссылке при файловых сертификатах.** SNI брался только
+ из ACME-блока, поэтому при `HY2XS_TLS_MODE=file` уходил пустым. Теперь
+ источник — ACME-домен, затем `HY2XS_DOMAIN`, затем `HY2XS_PUBLIC_HOST`;
+ IP-адрес в качестве SNI не используется.
+- **Утечка секретов в экспорте конфига.** Выгружаемый оператору YAML
+ содержал `trafficStats.secret`, `access_token` в auth-URL и пароль
+ обфускации. Секреты вырезаются, включая поля, о которых HY2XS ещё
+ не знает.
+- **Расхождение runtime-конфига с разобранным.** `renderRuntimeEnv`
+ печатал тип обфускации и режим auth литералами, игнорируя фактическую
+ конфигурацию, — из-за чего запись `/etc/hy2xs/hy2xs.env` могла разойтись
+ с тем, что реально применено.
+- **Смешение веток `obfs` в UI.** Форма склеивала дефолт с ответом API и
+ показывала блок обфускации, которого нет в конфиге сервера.
+- **`namedotcom` в списке ACME DNS-провайдеров.** Провайдер удалён из
+ Hysteria в 2.11.0; конфигурация с ним больше не запускается.
+- **Отсутствие подсказок в форме создания пира.** У полей «Пир»,
+ «Комментарий» и «Секрет» не было ни примеров, ни пояснений: оператор не
+ мог понять без документации, что секрет необязателен и генерируется
+ автоматически.
+
+### Безопасность
+
+- Переход на Hysteria 2.12.2 закрывает исправления, вышедшие после 2.8.2,
+ включая обход UDP ACL, возможный OOM через sniff и обход ACL через
+ домены с завершающей точкой (2.9.2).
+- Экспорт конфига больше не выносит секреты за пределы сервера.
+
+### Миграция с 0.x
+
+Автоматическая миграция не предусмотрена и не планируется.
+
+Порядок перехода:
+
+1. Выпишите с работающего сервера список пиров и их секреты.
+2. Разверните `1.0.0` на чистом Debian 13 из release-пакета.
+3. Заведите пиров заново и раздайте новые клиентские ссылки.
+
+Клиентские ссылки `0.x` в любом случае перестанут работать: смена
+обфускации — это изменение wire-совместимости.
+
+[Unreleased]: https://git.flamy.studio/prod/HY2XS_flamy/compare/v1.0.0...HEAD
+[1.0.0]: https://git.flamy.studio/prod/HY2XS_flamy/releases/tag/v1.0.0
diff --git a/README.md b/README.md
index 686fdc5..689f326 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,9 @@
Возможности ·
Быстрый старт ·
Конфигурация ·
+ Версии ·
Сборка ·
+ Changelog ·
Лицензия
@@ -39,7 +41,7 @@ HY2XS подходит для сценария, где нужен один produ
HY2XS release‑пакет разворачивает и настраивает:
-- официальный upstream‑бинарник Hysteria2, закреплённый в metadata пакета и проверяемый по SHA256;
+- официальный upstream‑бинарник Hysteria2: последняя стабильная версия выбирается при сборке пакета, закрепляется в его metadata и проверяется по SHA256;
- HY2XS admin — встроенную админ‑панель для управления users/peers, трафиком, конфигурацией, логами и состоянием сервера;
- systemd‑юнит `hysteria-server` для Hysteria2;
- systemd‑юнит `hy2xs-admin` для админ‑панели;
@@ -108,6 +110,8 @@ Windows и macOS можно использовать для разработки
- не включает IPv6‑production baseline;
- не настраивает `sshd` автоматически;
- не предоставляет полноценный uninstall/update framework;
+- не обновляет Hysteria2 на уже работающем сервере: `reconfigure` намеренно не является Hysteria updater;
+- не мигрирует установки `0.x` на `1.0.0` — переход выполняется чистой установкой, см. [CHANGELOG](CHANGELOG.md);
- не выполняет сложную миграцию старых неизвестных состояний сервера;
- не реализует Telegram‑бота, port hopping и универсальный access‑delivery workflow;
- не предназначен для установки поверх давно используемого сервера с неизвестными firewall/systemd‑правками.
@@ -147,10 +151,74 @@ hy2xs-install/
| TrafficStats Hysteria2 | `127.0.0.1:36712` |
| Firewall mode | `takeover` в packaged baseline |
| Hysteria2 auth | `http` через локальный HY2XS admin |
-| Hysteria2 obfs | `salamander` |
+| Hysteria2 obfs | `gecko` (512/1200); `salamander` доступен как режим совместимости |
+| Congestion fallback | `bbr`, профиль `standard` |
+| QUIC stateless reset | включён |
Важно: `HY2XS_SSH_PORT` нужен HY2XS для nftables‑правил и проверки доступности SSH‑порта. Сам `sshd` проект не перенастраивает. SSH на `2323` и вход только по ключу нужно настроить до запуска `./install.sh`.
+## Версионная политика Hysteria2
+
+HY2XS **не привязан к конкретному номеру версии Hysteria**.
+
+> Источник по умолчанию берёт последний стабильный релиз Hysteria, доступный на момент сборки пакета. Разрешённая версия, URL артефакта и контрольная сумма замораживаются в получившемся install‑пакете.
+
+Как это работает:
+
+```text
+build machine target server
+───────────── ─────────────
+определить последнюю стабильную ─┐
+скачать артефакт, посчитать SHA-256 │
+проверить, что бинарник принимает ├─► release‑пакет ──► скачать ровно
+канонический конфиг HY2XS │ version + url этот артефакт,
+заморозить version/url/sha256 ─┘ + sha256 сверить SHA-256
+ и `hysteria version`
+```
+
+Что это даёт:
+
+- новая установка получает актуальную Hysteria без ручного обновления version lock;
+- если между сборкой пакета и его установкой выйдет новая версия, **содержимое установки не изменится**;
+- повторная установка старого пакета поставит ту же версию, что и в день сборки;
+- несовместимый upstream ломает сборку, а не сервер оператора.
+
+Переопределения при сборке:
+
+```bash
+# по умолчанию: последняя стабильная
+./tools/build/build.sh
+
+# закрепить конкретную версию
+HYSTERIA_VERSION_OVERRIDE=v2.12.2 ./tools/build/build.sh
+
+# офлайн-сборка по закоммиченному tools/build/hysteria-lock.env
+HYSTERIA_CHANNEL=pinned ./tools/build/build.sh
+```
+
+Фактически установленная версия видна в `/etc/hysteria/post-install.env` (`HY2_VERSION`), а способ её выбора — в `HY2_RESOLUTION`.
+
+Обновление Hysteria на уже работающем сервере в текущем релизе не поддерживается: `reconfigure` намеренно не является Hysteria updater. Это сохраняет immutable‑контракт развёртывания.
+
+## Обфускация
+
+Новые установки HY2XS используют **Gecko**.
+
+Gecko помечен upstream как **experimental**. Он достраивается поверх Salamander: помимо scramble он дополнительно фрагментирует QUIC handshake на пакеты случайного размера. HY2XS использует upstream‑defaults размеров пакетов `512/1200` как проверенный production‑профиль.
+
+**Salamander остаётся поддержанным режимом совместимости.** Смена типа обфускации требует соответствующих изменений на клиенте: это изменение wire‑совместимости, а не косметическая настройка.
+
+| | Gecko | Salamander |
+| --- | --- | --- |
+| Статус upstream | experimental | stable |
+| Роль в HY2XS | default для новых установок | режим совместимости |
+| Параметр | `HY2XS_HYSTERIA_OBFS_TYPE=gecko` | `HY2XS_HYSTERIA_OBFS_TYPE=salamander` |
+| В клиентской ссылке | `obfs=gecko` | `obfs=salamander` |
+
+Экспериментальность upstream остаётся контролируемым риском, потому что одновременно выполняются три условия: Salamander доступен как fallback, каждая разрешённая версия проходит compatibility gate до выпуска пакета, и существующие серверы никогда не переводятся на Gecko молча.
+
+Размеры пакетов Gecko не выносятся в конфигурацию: официальная схема `hysteria2://` не умеет их передавать, поэтому нестандартные значения сделали бы клиентскую ссылку неполной.
+
## Быстрый старт для нового сервера
Ниже приведён полный путь для оператора, который работает с Windows и ставит HY2XS на чистый Debian 13 сервер.
@@ -379,10 +447,12 @@ HY2XS_IPV6_ENABLED=false
HY2XS_TLS_MODE=acme
HY2XS_ACME_TYPE=http
HY2XS_HYSTERIA_AUTH_MODE=http
-HY2XS_HYSTERIA_OBFS_TYPE=salamander
+HY2XS_HYSTERIA_OBFS_TYPE=gecko
HY2XS_UI_PUBLIC_ACCESS=false
```
+Если нужен режим совместимости со старыми клиентами, укажите `HY2XS_HYSTERIA_OBFS_TYPE=salamander`. Подробнее — в разделе [Обфускация](#обфускация).
+
### 9. Запустите установку
```bash
@@ -396,7 +466,7 @@ HY2XS_UI_PUBLIC_ACCESS=false
3. создаст runtime‑каталоги и service users;
4. запишет `/etc/hy2xs/hy2xs.env`;
5. разложит bundled HY2XS admin;
-6. скачает pinned Hysteria2 binary из upstream и проверит SHA256;
+6. скачает закреплённый в пакете Hysteria2 binary из upstream, проверит SHA256 и фактическую версию;
7. создаст `/etc/hysteria/config.yaml`;
8. установит systemd‑юниты;
9. применит nftables‑правила;
@@ -507,6 +577,7 @@ hy2xs-orchestrator status \
| Переменная | Назначение | Значение по умолчанию в packaged baseline |
| --- | --- | --- |
+| `HY2XS_CONFIG_SCHEMA_VERSION` | Версия схемы конфигурации HY2XS. Конфигурация другой схемы отклоняется fail‑fast | `2` |
| `HY2XS_IPV6_ENABLED` | IPv6‑режим. В production baseline должен быть `false` | `false` |
| `HY2XS_DOMAIN` | Домен для ACME и deploy‑профиля | `fi.api.withen.pro` |
| `HY2XS_DNS_AAAA_POLICY` | Поведение при наличии AAAA‑записи: `strict`, `warn`, `off` | `strict` |
@@ -534,8 +605,8 @@ hy2xs-orchestrator status \
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API | `127.0.0.1` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT` | Порт trafficStats API | `36712` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` |
-| `HY2XS_HYSTERIA_OBFS_TYPE` | Obfuscation type. Фиксированное значение production‑профиля | `salamander` |
-| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Salamander password | `__GENERATE__` |
+| `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` |
+| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Пароль обфускации; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_HYSTERIA_BANDWIDTH_UP` | Hysteria2 upstream bandwidth | `50 mbps` |
| `HY2XS_HYSTERIA_BANDWIDTH_DOWN` | Hysteria2 downstream bandwidth | `50 mbps` |
| `HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH` | Игнорировать bandwidth клиента | `false` |
@@ -717,7 +788,7 @@ git status --short
Подготовьте build env:
```bash
-export PACKAGE_VERSION=0.2.2
+export PACKAGE_VERSION=1.0.0
export BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ)
# Для переносимости между x86_64-серверами без AVX2 предпочтителен baseline artifact.
@@ -728,6 +799,9 @@ export BUN_FLAVOR=x64-baseline
export GO_ARCHIVE_SHA256=
export NODE_ARCHIVE_SHA256=
export BUN_ARCHIVE_SHA256=
+
+# Опционально: снимает anonymous rate limit при разрешении upstream-релиза.
+export GITHUB_TOKEN=
```
Запустите сборку:
@@ -736,6 +810,32 @@ export BUN_ARCHIVE_SHA256=
./tools/build/build.sh
```
+Сборка последовательно:
+
+1. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
+2. определяет последнюю стабильную версию Hysteria, скачивает артефакт и считает SHA‑256;
+3. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
+4. собирает orchestrator, frontend и backend;
+5. прогоняет `go vet` и `go test` для HY2XS admin;
+6. формирует архив и прогоняет acceptance‑проверки.
+
+Любой сбой на шагах 1–5 останавливает сборку до создания пакета.
+
+Переменные, управляющие выбором версии Hysteria:
+
+| Переменная | По умолчанию | Назначение |
+| --- | --- | --- |
+| `HYSTERIA_CHANNEL` | `stable` | `stable` — разрешить последнюю стабильную; `pinned` — офлайн‑сборка по `tools/build/hysteria-lock.env` |
+| `HYSTERIA_VERSION_OVERRIDE` | пусто | Закрепить конкретную версию `vX.Y.Z` |
+| `HYSTERIA_COMPAT_GATE` | `true` | Compatibility gate; для release‑сборок обязателен |
+| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл |
+
+Полный E2E с реальным клиентом Hysteria запускается отдельно:
+
+```bash
+HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
+```
+
Результат:
```text
@@ -748,7 +848,7 @@ dist/hy2xs-install-.tar.gz
ls -lh dist/hy2xs-install-*.tar.gz
sha256sum dist/hy2xs-install-*.tar.gz
-tar -tzf dist/hy2xs-install-0.2.2.tar.gz | grep -E \
+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)$'
```
@@ -762,6 +862,9 @@ tar -tzf dist/hy2xs-install-0.2.2.tar.gz | grep -E \
├── orchestrator/ # install-only orchestrator на Bun + TypeScript
├── package/ # skeleton будущего install package
├── tools/build/ # production builder и packaging pipeline
+├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria
+├── docs/ # спецификации baseline, тестов и эксплуатации
+├── CHANGELOG.md
├── README.md
└── LICENSE
```
diff --git a/apps/controller/config.go b/apps/controller/config.go
index fd4ebad..2c1e86b 100644
--- a/apps/controller/config.go
+++ b/apps/controller/config.go
@@ -4,9 +4,6 @@ import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "hy2xs-admin/dao"
- "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
@@ -183,72 +180,24 @@ func UpdateHysteria2Config(c *gin.Context) {
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
}
+// ExportHysteria2Config отдаёт оператору фактический серверный конфиг.
+//
+// Экспорт работает от исходного YAML, а не от типизированной модели: поля,
+// о которых HY2XS ещё не знает, обязаны пережить выгрузку. Секреты при этом
+// вырезаются — файл покидает сервер.
func ExportHysteria2Config(c *gin.Context) {
- hysteria2ServerConfig, err := service.GetHysteria2Config()
+ sanitized, err := service.ExportHysteria2ConfigYaml()
if err != nil {
vo.Fail(err.Error(), c)
return
}
- // Значения по умолчанию
- config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.Hysteria2TrafficStatsSecret})
- if err != nil {
- vo.Fail(err.Error(), c)
- return
- }
-
- var hUIWebPort string
- var trafficStatsSecret string
- for _, item := range config {
- if *item.Key == constant.HUIWebPort {
- hUIWebPort = *item.Value
- } else if *item.Key == constant.Hysteria2TrafficStatsSecret {
- trafficStatsSecret = *item.Value
- }
- }
-
- if hUIWebPort == "" || trafficStatsSecret == "" {
- logrus.Errorf("hUIWebPort or trafficStatsSecret is nil")
- vo.Fail(constant.SysError, c)
- return
- }
-
- authHttpUrl, err := service.GetAuthHttpUrl()
- if err != nil {
- vo.Fail(err.Error(), c)
- return
- }
-
- authType := "http"
- authHttpInsecure := true
- var auth bo.ServerConfigAuth
- auth.Type = &authType
- var http bo.ServerConfigAuthHTTP
- http.URL = &authHttpUrl
- http.Insecure = &authHttpInsecure
- auth.HTTP = &http
- hysteria2ServerConfig.Auth = &auth
- if hysteria2ServerConfig.TrafficStats == nil {
- hysteria2ServerConfig.TrafficStats = &bo.ServerConfigTrafficStats{}
- }
- hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
-
fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405"))
- filePath := filepath.Join(constant.ExportPathDir, fileName)
- if err = util.ExportFile(filePath, hysteria2ServerConfig, 1); err != nil {
- vo.Fail(err.Error(), c)
- return
- }
-
- if !util.Exists(filePath) {
- vo.Fail("file not exist", c)
- return
- }
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
- c.File(filePath)
+ c.Data(200, "application/octet-stream", sanitized)
}
func ImportHysteria2Config(c *gin.Context) {
diff --git a/apps/frontend/src/api/config/types.ts b/apps/frontend/src/api/config/types.ts
index d180cc9..22d7c5d 100644
--- a/apps/frontend/src/api/config/types.ts
+++ b/apps/frontend/src/api/config/types.ts
@@ -26,6 +26,10 @@ export interface Hysteria2ServerConfig {
cert: string;
key: string;
sniGuard?: string;
+ clientCA?: string;
+ };
+ ech?: {
+ keyPath?: string;
};
acme?: {
domains: string[];
@@ -51,9 +55,14 @@ export interface Hysteria2ServerConfig {
};
obfs?: {
type: string;
- salamander: {
+ salamander?: {
password: string;
};
+ gecko?: {
+ password: string;
+ minPacketSize?: number;
+ maxPacketSize?: number;
+ };
};
quic?: {
initStreamReceiveWindow?: number;
@@ -63,10 +72,16 @@ export interface Hysteria2ServerConfig {
maxIdleTimeout?: string;
maxIncomingStreams?: number;
disablePathMTUDiscovery?: boolean;
+ disableStatelessReset?: boolean;
};
bandwidth?: {
up: string;
down: string;
+ disableLossCompensation?: boolean;
+ };
+ congestion?: {
+ type?: string;
+ bbrProfile?: string;
};
ignoreClientBandwidth?: boolean;
speedTest?: boolean;
@@ -122,6 +137,7 @@ export interface Hysteria2ServerConfig {
url: string;
rewriteHost: boolean;
insecure: boolean;
+ xForwarded?: boolean;
};
string?: {
content: string;
@@ -132,6 +148,26 @@ export interface Hysteria2ServerConfig {
listenHTTPS?: string;
forceHTTPS?: boolean;
};
+ mimic?: {
+ enabled?: boolean;
+ interface?: string;
+ xdpMode?: string;
+ path?: string;
+ extraArgs?: string[];
+ };
+ realm?: {
+ stunServers?: string[];
+ stunTimeout?: string;
+ punchTimeout?: string;
+ heartbeatInterval?: string;
+ insecure?: boolean;
+ ipMode?: string;
+ portMapping?: {
+ enabled?: boolean;
+ timeout?: string;
+ lifetime?: string;
+ };
+ };
}
export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
@@ -144,9 +180,9 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
acme: {
domains: [],
email: "",
- ca: "zerossl",
+ ca: "letsencrypt",
listenHost: "0.0.0.0",
- dir: "my_acme_dir",
+ dir: "/var/lib/hysteria/acme",
type: "",
http: {
altPort: 8888,
@@ -155,7 +191,7 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
altPort: 44333,
},
dns: {
- name: "gomommy",
+ name: "cloudflare",
config: {},
},
disableHTTP: false,
@@ -164,9 +200,11 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
altTLSALPNPort: 443,
},
obfs: {
- type: "salamander",
- salamander: {
- password: "cry_me_a_r1ver",
+ type: "gecko",
+ gecko: {
+ password: "",
+ minPacketSize: 512,
+ maxPacketSize: 1200,
},
},
quic: {
@@ -177,10 +215,16 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
maxIdleTimeout: "30s",
maxIncomingStreams: 1024,
disablePathMTUDiscovery: false,
+ disableStatelessReset: false,
},
bandwidth: {
- up: "1 gbps",
- down: "1 gbps",
+ up: "50 mbps",
+ down: "50 mbps",
+ disableLossCompensation: false,
+ },
+ congestion: {
+ type: "bbr",
+ bbrProfile: "standard",
},
ignoreClientBandwidth: false,
speedTest: false,
@@ -236,6 +280,7 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
url: "",
rewriteHost: true,
insecure: false,
+ xForwarded: false,
},
string: {
content: "hello stupid world",
diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts
index 195d944..480d8eb 100644
--- a/apps/frontend/src/lang/package/en.ts
+++ b/apps/frontend/src/lang/package/en.ts
@@ -128,6 +128,17 @@ export default {
name: "Peer",
remark: "Remark",
secret: "Secret",
+ form: {
+ namePlaceholder: "e.g. ivan-laptop",
+ nameHint:
+ "Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret.",
+ remarkPlaceholder: "e.g. Ivan's laptop, sales team",
+ remarkHint: "Optional operator note. It is never shown to the client.",
+ secretPlaceholder: "leave empty to generate automatically",
+ secretHint:
+ "Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.",
+ quotaHint: "Traffic limit in bytes. Use -1 for unlimited.",
+ },
maxDevices: "Max devices",
disabled: "Disabled",
status: "Status",
@@ -234,6 +245,7 @@ export default {
obfs: "Obfuscation",
quic: "QUIC parameters",
bandwidth: "Bandwidth",
+ congestion: "Congestion control",
speedTest: "Speed Test",
udp: "UDP",
resolver: "Resolver",
@@ -282,10 +294,17 @@ export default {
"Alternate TLS-ALPN challenge port. (Note: If you want to use anything other than 443, you must set up port forward/SNI proxy from 443 to that port, otherwise ACME will not be able to issue the certificate.)",
},
obfs: {
- type: "Type",
+ type: "Obfuscation type: gecko (HY2XS production default) or salamander (compatibility fallback).",
salamander: {
password: "Replace with a strong password of your choice.",
},
+ gecko: {
+ password: "Replace with a strong password of your choice.",
+ minPacketSize:
+ "Minimum QUIC handshake fragment size. HY2XS baseline: 512.",
+ maxPacketSize:
+ "Maximum QUIC handshake fragment size. HY2XS baseline: 1200, upstream limit: 2048.",
+ },
},
quic: {
initStreamReceiveWindow: "The initial QUIC stream receive window size.",
@@ -299,10 +318,18 @@ export default {
maxIncomingStreams:
"The maximum number of concurrent incoming streams.",
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.",
+ disableStatelessReset:
+ "Disable QUIC stateless reset. Kept off in the HY2XS baseline: stateless reset lets a client with a stale connection reconnect immediately after a server restart or device sleep.",
},
bandwidth: {
up: "Up",
down: "Down",
+ disableLossCompensation:
+ "Disable loss compensation. Kept off in the HY2XS baseline, so compensation stays active.",
+ },
+ congestion: {
+ type: "Fallback congestion controller: bbr or reno. Used when Brutal bandwidth is not negotiated by both sides.",
+ bbrProfile: "BBR profile: standard, conservative or aggressive.",
},
ignoreClientBandwidth:
"When enabled, makes the server to disregard any bandwidth hints set by clients",
diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts
index f2aecbc..b66c325 100644
--- a/apps/frontend/src/lang/package/ru.ts
+++ b/apps/frontend/src/lang/package/ru.ts
@@ -124,6 +124,17 @@ export default {
name: "Пир",
remark: "Комментарий",
secret: "Секрет",
+ form: {
+ namePlaceholder: "например, ivan-laptop",
+ nameHint:
+ "Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет.",
+ remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
+ remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
+ secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
+ secretHint:
+ "Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
+ quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
+ },
maxDevices: "Лимит устройств",
disabled: "Отключён",
status: "Статус",
@@ -230,6 +241,7 @@ export default {
obfs: "Маскировка",
quic: "Параметры QUIC",
bandwidth: "Полоса",
+ congestion: "Congestion control",
speedTest: "Тест скорости",
udp: "UDP",
resolver: "DNS",
@@ -264,8 +276,15 @@ export default {
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
},
obfs: {
- type: "Тип",
+ type: "Тип обфускации: gecko (production default HY2XS) или salamander (совместимость)",
salamander: { password: "Сильный пароль Salamander" },
+ gecko: {
+ password: "Сильный пароль Gecko",
+ minPacketSize:
+ "Минимальный размер фрагмента QUIC handshake. Baseline HY2XS: 512",
+ maxPacketSize:
+ "Максимальный размер фрагмента QUIC handshake. Baseline HY2XS: 1200, верхний предел upstream: 2048",
+ },
},
quic: {
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
@@ -275,8 +294,19 @@ export default {
maxIdleTimeout: "Максимальный idle timeout",
maxIncomingStreams: "Максимум входящих stream",
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
+ disableStatelessReset:
+ "Отключить QUIC stateless reset. В baseline HY2XS выключено: stateless reset ускоряет переподключение клиента после перезапуска сервера или сна устройства",
+ },
+ bandwidth: {
+ up: "Вверх",
+ down: "Вниз",
+ disableLossCompensation:
+ "Отключить компенсацию потерь. В baseline HY2XS выключено, то есть компенсация работает",
+ },
+ congestion: {
+ type: "Fallback congestion controller: bbr или reno. Применяется, когда Brutal bandwidth не согласован сторонами",
+ bbrProfile: "Профиль BBR: standard, conservative или aggressive",
},
- bandwidth: { up: "Вверх", down: "Вниз" },
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
speedTest: "Встроенный сервер теста скорости",
disableUDP: "Отключить UDP forwarding",
diff --git a/apps/frontend/src/views/hysteria/list/index.vue b/apps/frontend/src/views/hysteria/list/index.vue
index f892047..72638ee 100644
--- a/apps/frontend/src/views/hysteria/list/index.vue
+++ b/apps/frontend/src/views/hysteria/list/index.vue
@@ -361,21 +361,69 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(["dns-san", "disable", "strict"]);
const aclTypes = ref(["file", "inline"]);
const acmeCas = ref(["zerossl", "letsencrypt"]);
const acmeTypes = ref(["http", "tls", "dns"]);
+// Актуальный upstream-реестр ACME DNS-провайдеров.
+// `namedotcom` удалён в Hysteria 2.11.0 вместе с переписанным ACME-стеком:
+// конфигурация с ним больше не запускается.
const dnsNames = ref([
"cloudflare",
"duckdns",
"gandi",
"godaddy",
- "namedotcom",
+ "namecheap",
+ "njalla",
+ "porkbun",
"vultr",
]);
-const obfsTypes = ref(["salamander"]);
+const obfsTypes = ref(["gecko", "salamander"]);
+const congestionTypes = ref(["bbr", "reno"]);
+const bbrProfiles = ref(["standard", "conservative", "aggressive"]);
const resolverTypes = ref(["tcp", "udp", "tls", "https"]);
const masqueradeTypes = ref(["file", "proxy", "string"]);
@@ -1043,6 +1169,7 @@ const state = reactive({
obfs: false,
quic: false,
bandwidth: false,
+ congestion: false,
speedTest: false,
udp: false,
resolver: false,
@@ -1067,6 +1194,7 @@ const {
obfs,
quic,
bandwidth,
+ congestion,
speedTest,
udp,
resolver,
@@ -1120,6 +1248,7 @@ const setConfig = () => {
state.obfs = !!data?.obfs;
state.quic = !!data?.quic;
state.bandwidth = !!data?.bandwidth;
+ state.congestion = !!data?.congestion;
state.speedTest = !!data?.speedTest;
state.udp = !!data?.disableUDP || !!data?.udpIdleTimeout;
state.resolver = !!data?.resolver;
@@ -1130,10 +1259,28 @@ const setConfig = () => {
state.dataForm = deepCopy(defaultHysteria2ServerConfig);
assignWith(state.dataForm, data);
+ dropInactiveObfsSubtype(state.dataForm, data);
}
});
};
+// Форма строится как merge дефолта и ответа API, поэтому неактивная ветка obfs
+// осталась бы от дефолта и UI показал бы блок, которого нет в конфиге сервера.
+const dropInactiveObfsSubtype = (
+ form: Hysteria2ServerConfig,
+ data: Hysteria2ServerConfig
+) => {
+ if (!form.obfs) {
+ return;
+ }
+ if (form.obfs.type !== "gecko" || !data?.obfs?.gecko) {
+ delete form.obfs.gecko;
+ }
+ if (form.obfs.type !== "salamander" || !data?.obfs?.salamander) {
+ delete form.obfs.salamander;
+ }
+};
+
const setHysteria2Monitor = async () => {
const { data } = await dashboardSummaryApi();
state.hysteria2Monitor.version = data.hysteria.version;
diff --git a/apps/frontend/src/views/peer/list/index.vue b/apps/frontend/src/views/peer/list/index.vue
index b8956d2..6ab939b 100644
--- a/apps/frontend/src/views/peer/list/index.vue
+++ b/apps/frontend/src/views/peer/list/index.vue
@@ -65,9 +65,7 @@
- {{
- formatBytes(scope.row.downloadBytes + scope.row.uploadBytes)
- }}
+ {{ formatBytes(scope.row.downloadBytes + scope.row.uploadBytes) }}
/ {{ quotaText(scope.row.quotaBytes) }}
-
-
-
-
+
+
+ {{ $t("peer.form.nameHint") }}
+
+
+
+ {{ $t("peer.form.remarkHint") }}
+
+
+
+ {{ $t("peer.form.secretHint") }}
+
+
+
+ {{ $t("peer.form.quotaHint") }}
+
diff --git a/apps/model/bo/hysteria2.go b/apps/model/bo/hysteria2.go
index 9005662..2ea79f1 100644
--- a/apps/model/bo/hysteria2.go
+++ b/apps/model/bo/hysteria2.go
@@ -1,12 +1,55 @@
package bo
+// ObfsShareConfig — та часть обфускации, которую способна описать официальная
+// схема hysteria2:// URI: только type и password.
+//
+// Gecko minPacketSize/maxPacketSize в ссылку не помещаются. Именно поэтому
+// HY2XS фиксирует их на upstream defaults 512/1200: иначе сгенерированная
+// ссылка не описывала бы подключение полностью.
+type ObfsShareConfig struct {
+ Type string
+ Password string
+}
+
+// ObfsShare приводит любой поддерживаемый тип обфускации к единому виду для
+// генерации клиентской ссылки. Знание о подтипах живёт рядом с моделью, чтобы
+// добавление нового типа не требовало правок в слое share URI.
+func (c *Hysteria2ServerConfig) ObfsShare() ObfsShareConfig {
+ if c == nil || c.Obfs == nil || c.Obfs.Type == nil {
+ return ObfsShareConfig{}
+ }
+
+ switch *c.Obfs.Type {
+ case "salamander":
+ if c.Obfs.Salamander != nil && c.Obfs.Salamander.Password != nil {
+ return ObfsShareConfig{Type: "salamander", Password: *c.Obfs.Salamander.Password}
+ }
+ case "gecko":
+ if c.Obfs.Gecko != nil && c.Obfs.Gecko.Password != nil {
+ return ObfsShareConfig{Type: "gecko", Password: *c.Obfs.Gecko.Password}
+ }
+ }
+
+ return ObfsShareConfig{}
+}
+
+// AcmeDomain возвращает первый ACME-домен, если он есть.
+func (c *Hysteria2ServerConfig) AcmeDomain() string {
+ if c == nil || c.ACME == nil || len(c.ACME.Domains) == 0 {
+ return ""
+ }
+ return c.ACME.Domains[0]
+}
+
type Hysteria2ServerConfig struct {
Listen *string `yaml:"listen,omitempty" json:"listen" validate:"required"`
Obfs *serverConfigObfs `yaml:"obfs,omitempty" json:"obfs" validate:"omitempty"`
TLS *serverConfigTLS `yaml:"tls,omitempty" json:"tls" validate:"omitempty"`
ACME *serverConfigACME `yaml:"acme,omitempty" json:"acme" validate:"omitempty"`
+ ECH *serverConfigECH `yaml:"ech,omitempty" json:"ech" validate:"omitempty"`
QUIC *serverConfigQUIC `yaml:"quic,omitempty" json:"quic" validate:"omitempty"`
Bandwidth *serverConfigBandwidth `yaml:"bandwidth,omitempty" json:"bandwidth" validate:"omitempty"`
+ Congestion *ServerConfigCongestion `yaml:"congestion,omitempty" json:"congestion" validate:"omitempty"`
IgnoreClientBandwidth *bool `yaml:"ignoreClientBandwidth,omitempty" json:"ignoreClientBandwidth" validate:"omitempty"`
SpeedTest *bool `yaml:"speedTest,omitempty" json:"speedTest" validate:"omitempty"`
DisableUDP *bool `yaml:"disableUDP,omitempty" json:"disableUDP" validate:"omitempty"`
@@ -18,21 +61,43 @@ type Hysteria2ServerConfig struct {
Outbounds []serverConfigOutboundEntry `yaml:"outbounds,omitempty" json:"outbounds" validate:"omitempty"`
TrafficStats *ServerConfigTrafficStats `yaml:"trafficStats,omitempty" json:"trafficStats" validate:"required"`
Masquerade *serverConfigMasquerade `yaml:"masquerade,omitempty" json:"masquerade" validate:"omitempty"`
+ Mimic *serverConfigMimic `yaml:"mimic,omitempty" json:"mimic" validate:"omitempty"`
+ Realm *serverConfigRealm `yaml:"realm,omitempty" json:"realm" validate:"omitempty"`
}
type serverConfigObfsSalamander struct {
Password *string `yaml:"password,omitempty" json:"password" validate:"required"`
}
+// serverConfigObfsGecko описывает Gecko-обфускацию (Hysteria 2.9.2+).
+// Gecko достраивается поверх Salamander и дополнительно фрагментирует
+// QUIC handshake на пакеты случайного размера в диапазоне min..max.
+type serverConfigObfsGecko struct {
+ Password *string `yaml:"password,omitempty" json:"password" validate:"required"`
+ MinPacketSize *int `yaml:"minPacketSize,omitempty" json:"minPacketSize" validate:"omitempty"`
+ MaxPacketSize *int `yaml:"maxPacketSize,omitempty" json:"maxPacketSize" validate:"omitempty"`
+}
+
+// Обе ветки опциональны: в конфиге присутствует ровно одна из них,
+// соответствующая Type.
type serverConfigObfs struct {
Type *string `yaml:"type,omitempty" json:"type" validate:"required"`
- Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"required"`
+ Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"omitempty"`
+ Gecko *serverConfigObfsGecko `yaml:"gecko,omitempty" json:"gecko" validate:"omitempty"`
}
type serverConfigTLS struct {
Cert *string `yaml:"cert,omitempty" json:"cert" validate:"required"`
Key *string `yaml:"key,omitempty" json:"key" validate:"required"`
SNIGuard *string `yaml:"sniGuard,omitempty" json:"sniGuard" validate:"omitempty"`
+ ClientCA *string `yaml:"clientCA,omitempty" json:"clientCA" validate:"omitempty"`
+}
+
+// serverConfigECH — Encrypted Client Hello (Hysteria 2.10.0+).
+// HY2XS не генерирует ECH keypair и не включает ECH в default-профиле,
+// но обязан корректно читать и сохранять этот блок.
+type serverConfigECH struct {
+ KeyPath *string `yaml:"keyPath,omitempty" json:"keyPath" validate:"omitempty"`
}
type serverConfigACME struct {
@@ -78,11 +143,25 @@ type serverConfigQUIC struct {
MaxIdleTimeout *string `yaml:"maxIdleTimeout,omitempty" json:"maxIdleTimeout" validate:"omitempty"`
MaxIncomingStreams *int64 `yaml:"maxIncomingStreams,omitempty" json:"maxIncomingStreams" validate:"omitempty"`
DisablePathMTUDiscovery *bool `yaml:"disablePathMTUDiscovery,omitempty" json:"disablePathMTUDiscovery" validate:"omitempty"`
+ // DisableStatelessReset появился в Hysteria 2.12.2. HY2XS оставляет
+ // stateless reset включённым: клиент после перезапуска сервера или сна
+ // устройства переподключается сразу, а не по таймауту.
+ DisableStatelessReset *bool `yaml:"disableStatelessReset,omitempty" json:"disableStatelessReset" validate:"omitempty"`
}
type serverConfigBandwidth struct {
Up *string `yaml:"up,omitempty" json:"up" validate:"required"`
Down *string `yaml:"down,omitempty" json:"down" validate:"required"`
+ // DisableLossCompensation появился в Hysteria 2.10.0.
+ DisableLossCompensation *bool `yaml:"disableLossCompensation,omitempty" json:"disableLossCompensation" validate:"omitempty"`
+}
+
+// ServerConfigCongestion — fallback congestion controller. Используется, когда
+// Brutal bandwidth не согласован сторонами. Это не то же самое, что host-level
+// BBR ядра Linux: у Hysteria собственный congestion-control контур.
+type ServerConfigCongestion struct {
+ Type *string `yaml:"type,omitempty" json:"type" validate:"omitempty"`
+ BBRProfile *string `yaml:"bbrProfile,omitempty" json:"bbrProfile" validate:"omitempty"`
}
type ServerConfigAuthHTTP struct {
@@ -183,9 +262,11 @@ type serverConfigMasqueradeFile struct {
}
type serverConfigMasqueradeProxy struct {
+ // URL с Hysteria 2.12.2 может указывать и на unix socket.
URL *string `yaml:"url,omitempty" json:"url" validate:"required"`
RewriteHost *bool `yaml:"rewriteHost,omitempty" json:"rewriteHost" validate:"required"`
Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"`
+ XForwarded *bool `yaml:"xForwarded,omitempty" json:"xForwarded" validate:"omitempty"`
}
type serverConfigMasqueradeString struct {
@@ -203,3 +284,35 @@ type serverConfigMasquerade struct {
ListenHTTPS *string `yaml:"listenHTTPS,omitempty" json:"listenHTTPS" validate:"omitempty"`
ForceHTTPS *bool `yaml:"forceHTTPS,omitempty" json:"forceHTTPS" validate:"omitempty"`
}
+
+// serverConfigMimic — Mimic / fake TCP (Hysteria 2.12.0+).
+// Требует отдельный сторонний бинарник, root и eBPF/XDP, поэтому в
+// production-профиле HY2XS выключен: текущий systemd-контракт запускает
+// Hysteria под непривилегированным пользователем.
+type serverConfigMimic struct {
+ Enabled *bool `yaml:"enabled,omitempty" json:"enabled" validate:"omitempty"`
+ Interface *string `yaml:"interface,omitempty" json:"interface" validate:"omitempty"`
+ XDPMode *string `yaml:"xdpMode,omitempty" json:"xdpMode" validate:"omitempty"`
+ Path *string `yaml:"path,omitempty" json:"path" validate:"omitempty"`
+ ExtraArgs []string `yaml:"extraArgs,omitempty" json:"extraArgs" validate:"omitempty"`
+}
+
+type serverConfigRealmPortMapping struct {
+ Enabled *bool `yaml:"enabled,omitempty" json:"enabled" validate:"omitempty"`
+ Timeout *string `yaml:"timeout,omitempty" json:"timeout" validate:"omitempty"`
+ Lifetime *string `yaml:"lifetime,omitempty" json:"lifetime" validate:"omitempty"`
+}
+
+// serverConfigRealm — Hysteria Realms (2.9.0+), запуск сервера за NAT через
+// STUN/hole punching. Меняет фундаментальный сетевой контракт HY2XS
+// (выделенный сервер, публичный IPv4, UDP/443, own nftables), поэтому в
+// default-профиле не используется, но должен корректно читаться.
+type serverConfigRealm struct {
+ StunServers []string `yaml:"stunServers,omitempty" json:"stunServers" validate:"omitempty"`
+ StunTimeout *string `yaml:"stunTimeout,omitempty" json:"stunTimeout" validate:"omitempty"`
+ PunchTimeout *string `yaml:"punchTimeout,omitempty" json:"punchTimeout" validate:"omitempty"`
+ HeartbeatInterval *string `yaml:"heartbeatInterval,omitempty" json:"heartbeatInterval" validate:"omitempty"`
+ Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"omitempty"`
+ IPMode *string `yaml:"ipMode,omitempty" json:"ipMode" validate:"omitempty"`
+ PortMapping *serverConfigRealmPortMapping `yaml:"portMapping,omitempty" json:"portMapping" validate:"omitempty"`
+}
diff --git a/apps/service/hysteria2_api.go b/apps/service/hysteria2_api.go
index 292ad06..f89ab1b 100644
--- a/apps/service/hysteria2_api.go
+++ b/apps/service/hysteria2_api.go
@@ -4,6 +4,7 @@ import (
"errors"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
+ "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/proxy"
"net"
@@ -14,6 +15,28 @@ import (
"time"
)
+// resolveShareSni выбирает SNI для клиентской ссылки.
+//
+// ACME-домен — не единственный источник истины: в режиме tls (файловые
+// сертификаты) блока acme в конфиге нет, но домен продукта известен из
+// runtime-конфига. Публичный IPv4 в качестве SNI не используется.
+func resolveShareSni(acmeDomain string, publicHost string) string {
+ if domain := strings.TrimSpace(acmeDomain); domain != "" {
+ return domain
+ }
+ if domain := strings.TrimSpace(os.Getenv("HY2XS_DOMAIN")); domain != "" && !isIPAddress(domain) {
+ return domain
+ }
+ if host := strings.TrimSpace(publicHost); host != "" && !isIPAddress(host) {
+ return host
+ }
+ return ""
+}
+
+func isIPAddress(value string) bool {
+ return net.ParseIP(strings.TrimSpace(value)) != nil
+}
+
func resolvePublicEndpoint() (string, int, error) {
host := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_HOST"))
if host == "" || host == "0.0.0.0" {
@@ -135,20 +158,8 @@ func Hysteria2Url(accountId int64) (string, error) {
remark = *hysteria2ConfigRemark.Value
}
- obfsType := ""
- obfsPassword := ""
- if hysteria2Config.Obfs != nil &&
- hysteria2Config.Obfs.Type != nil &&
- hysteria2Config.Obfs.Salamander != nil &&
- hysteria2Config.Obfs.Salamander.Password != nil {
- obfsType = *hysteria2Config.Obfs.Type
- obfsPassword = *hysteria2Config.Obfs.Salamander.Password
- }
-
- sni := ""
- if hysteria2Config.ACME != nil && len(hysteria2Config.ACME.Domains) > 0 {
- sni = hysteria2Config.ACME.Domains[0]
- }
+ obfs := hysteria2Config.ObfsShare()
+ sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
secret := ""
if peer.SecretEncrypted != nil {
@@ -158,14 +169,20 @@ func Hysteria2Url(accountId int64) (string, error) {
}
secret = decrypted
}
- return buildHysteria2Url(secret, hostname, port, obfsType, obfsPassword, sni, remark), nil
+ return buildHysteria2Url(secret, hostname, port, obfs, sni, remark), nil
}
-func buildHysteria2Url(conPass string, hostname string, port int, obfsType string, obfsPassword string, sni string, remark string) string {
+// isShareableObfsType перечисляет типы обфускации, которые официальная
+// URI-схема Hysteria умеет передавать клиенту.
+func isShareableObfsType(obfsType string) bool {
+ return obfsType == "salamander" || obfsType == "gecko"
+}
+
+func buildHysteria2Url(conPass string, hostname string, port int, obfs bo.ObfsShareConfig, sni string, remark string) string {
query := url.Values{}
- if obfsType == "salamander" && obfsPassword != "" {
- query.Set("obfs", "salamander")
- query.Set("obfs-password", obfsPassword)
+ if isShareableObfsType(obfs.Type) && obfs.Password != "" {
+ query.Set("obfs", obfs.Type)
+ query.Set("obfs-password", obfs.Password)
}
if sni != "" {
query.Set("sni", sni)
diff --git a/apps/service/hysteria2_api_test.go b/apps/service/hysteria2_api_test.go
index c88bbc1..31e730d 100644
--- a/apps/service/hysteria2_api_test.go
+++ b/apps/service/hysteria2_api_test.go
@@ -4,26 +4,115 @@ import (
"net/url"
"strings"
"testing"
+
+ "hy2xs-admin/model/bo"
+
+ "gopkg.in/yaml.v3"
)
+func mustParse(t *testing.T, raw string) *url.URL {
+ t.Helper()
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("expected valid url, got error: %v (raw=%s)", err, raw)
+ }
+ if parsed.Scheme != "hysteria2" {
+ t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
+ }
+ return parsed
+}
+
+func TestBuildHysteria2Url_GeckoObfs(t *testing.T) {
+ raw := buildHysteria2Url(
+ "con-pass",
+ "vpn.example.com",
+ 443,
+ bo.ObfsShareConfig{Type: "gecko", Password: "gecko-secret"},
+ "vpn.example.com",
+ "",
+ )
+
+ q := mustParse(t, raw).Query()
+ if q.Get("obfs") != "gecko" {
+ t.Fatalf("expected obfs=gecko, got %q", q.Get("obfs"))
+ }
+ if q.Get("obfs-password") != "gecko-secret" {
+ t.Fatalf("expected gecko obfs password, got %q", q.Get("obfs-password"))
+ }
+ if q.Get("sni") != "vpn.example.com" {
+ t.Fatalf("expected sni, got %q", q.Get("sni"))
+ }
+ if q.Get("insecure") != "0" {
+ t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
+ }
+}
+
+func TestBuildHysteria2Url_SalamanderObfs(t *testing.T) {
+ raw := buildHysteria2Url(
+ "con-pass",
+ "vpn.example.com",
+ 443,
+ bo.ObfsShareConfig{Type: "salamander", Password: "salamander-secret"},
+ "vpn.example.com",
+ "",
+ )
+
+ q := mustParse(t, raw).Query()
+ if q.Get("obfs") != "salamander" {
+ t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
+ }
+ if q.Get("obfs-password") != "salamander-secret" {
+ t.Fatalf("expected salamander obfs password, got %q", q.Get("obfs-password"))
+ }
+}
+
+func TestBuildHysteria2Url_NoObfs(t *testing.T) {
+ raw := buildHysteria2Url("pass", "example.com", 8443, bo.ObfsShareConfig{}, "", "")
+
+ parsed := mustParse(t, raw)
+ if parsed.Host != "example.com:8443" {
+ t.Fatalf("unexpected host: %s", parsed.Host)
+ }
+ q := parsed.Query()
+ if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
+ t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
+ }
+ if q.Get("insecure") != "0" {
+ t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
+ }
+}
+
+func TestBuildHysteria2Url_UnknownObfsTypeIsNotShared(t *testing.T) {
+ // Неизвестный тип не должен попадать в ссылку: клиент получил бы
+ // параметр, который не понимает.
+ raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "future-obfs", Password: "x"}, "", "")
+
+ q := mustParse(t, raw).Query()
+ if q.Get("obfs") != "" {
+ t.Fatalf("unknown obfs type must not be shared, got %q", q.Get("obfs"))
+ }
+}
+
+func TestBuildHysteria2Url_ObfsWithoutPasswordIsNotShared(t *testing.T) {
+ raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "gecko"}, "", "")
+
+ q := mustParse(t, raw).Query()
+ if q.Get("obfs") != "" || q.Get("obfs-password") != "" {
+ t.Fatalf("obfs without password must not be shared: %s", q.Encode())
+ }
+}
+
func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
raw := buildHysteria2Url(
"u@ser:#&=+ pass",
"example.com",
443,
- "salamander",
- "obf+s&pass=@x",
+ bo.ObfsShareConfig{Type: "gecko", Password: "obf+s&pass=@x"},
"exa mple.com",
"my remark #1",
)
- parsed, err := url.Parse(raw)
- if err != nil {
- t.Fatalf("expected valid url, got error: %v", err)
- }
- if parsed.Scheme != "hysteria2" {
- t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
- }
+ parsed := mustParse(t, raw)
if parsed.User == nil {
t.Fatal("expected userinfo to be present")
}
@@ -32,42 +121,200 @@ func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
}
q := parsed.Query()
- if q.Get("obfs") != "salamander" {
- t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
- }
if q.Get("obfs-password") != "obf+s&pass=@x" {
t.Fatalf("expected decoded obfs-password, got %q", q.Get("obfs-password"))
}
if q.Get("sni") != "exa mple.com" {
t.Fatalf("expected decoded sni, got %q", q.Get("sni"))
}
- if q.Get("insecure") != "0" {
- t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
- }
if parsed.Fragment != "my remark #1" {
t.Fatalf("expected decoded fragment, got %q", parsed.Fragment)
}
-
if strings.Contains(raw, "u@ser:#&=+ pass") {
t.Fatalf("raw uri must not contain unescaped userinfo: %s", raw)
}
}
-func TestBuildHysteria2Url_MinimalConfig(t *testing.T) {
- raw := buildHysteria2Url("pass", "example.com", 8443, "", "", "", "")
+// Регрессия на upstream-баг 2.9.3: `+` в credentials при разборе share link
+// превращался в пробел. Проверяем, что кодирование однозначно.
+func TestBuildHysteria2Url_PlusInCredentialsSurvivesRoundTrip(t *testing.T) {
+ cases := []string{"a+b", "a b", "a#b", "a@b", "a/b", "a?b", "a&b", "a=b", "a%b", "тест"}
- parsed, err := url.Parse(raw)
- if err != nil {
- t.Fatalf("expected valid url, got error: %v", err)
+ for _, value := range cases {
+ raw := buildHysteria2Url(value, "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: value}, "", "")
+ parsed := mustParse(t, raw)
+
+ if got := parsed.User.Username(); got != value {
+ t.Fatalf("userinfo round-trip failed for %q: got %q (raw=%s)", value, got, raw)
+ }
+ if got := parsed.Query().Get("obfs-password"); got != value {
+ t.Fatalf("obfs-password round-trip failed for %q: got %q (raw=%s)", value, got, raw)
+ }
}
- if parsed.Host != "example.com:8443" {
- t.Fatalf("unexpected host: %s", parsed.Host)
+
+ // Ключевой инвариант: литеральный `+` кодируется как %2B и не может быть
+ // прочитан клиентом как пробел, а пробел кодируется отдельно от него.
+ plus := buildHysteria2Url("a+b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a+b"}, "", "")
+ if !strings.Contains(mustParse(t, plus).RawQuery, "%2B") {
+ t.Fatalf("literal '+' must be percent-encoded as %%2B: %s", plus)
}
- q := parsed.Query()
- if q.Get("insecure") != "0" {
- t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
- }
- if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
- t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
+
+ space := buildHysteria2Url("a b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a b"}, "", "")
+ if mustParse(t, space).Query().Get("obfs-password") == "a+b" {
+ t.Fatalf("space and '+' must not collapse to the same value: %s", space)
+ }
+}
+
+func TestResolveShareSni(t *testing.T) {
+ tests := []struct {
+ name string
+ acmeDomain string
+ publicHost string
+ envDomain string
+ want string
+ }{
+ {name: "acme domain wins", acmeDomain: "acme.example.com", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "acme.example.com"},
+ {name: "file tls falls back to product domain", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "env.example.com"},
+ {name: "public host used when domain is empty", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "", want: "vpn.example.com"},
+ {name: "ipv4 public host is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "", want: ""},
+ {name: "ipv4 env domain is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "198.51.100.7", want: ""},
+ {name: "whitespace is trimmed", acmeDomain: " acme.example.com ", publicHost: "", envDomain: "", want: "acme.example.com"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv("HY2XS_DOMAIN", tc.envDomain)
+ if got := resolveShareSni(tc.acmeDomain, tc.publicHost); got != tc.want {
+ t.Fatalf("resolveShareSni(%q, %q) with HY2XS_DOMAIN=%q = %q, want %q",
+ tc.acmeDomain, tc.publicHost, tc.envDomain, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestObfsShare_FromServerConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ yamlConfig string
+ wantType string
+ wantPassword string
+ }{
+ {
+ name: "gecko",
+ yamlConfig: "obfs:\n type: gecko\n gecko:\n password: gecko-pass\n minPacketSize: 512\n maxPacketSize: 1200\n",
+ wantType: "gecko",
+ wantPassword: "gecko-pass",
+ },
+ {
+ name: "salamander",
+ yamlConfig: "obfs:\n type: salamander\n salamander:\n password: salamander-pass\n",
+ wantType: "salamander",
+ wantPassword: "salamander-pass",
+ },
+ {
+ name: "no obfs section",
+ yamlConfig: "listen: 0.0.0.0:443\n",
+ },
+ {
+ name: "type without matching subsection",
+ yamlConfig: "obfs:\n type: gecko\n salamander:\n password: mismatched\n",
+ },
+ {
+ name: "unknown type",
+ yamlConfig: "obfs:\n type: future\n gecko:\n password: p\n",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ var config bo.Hysteria2ServerConfig
+ if err := yaml.Unmarshal([]byte(tc.yamlConfig), &config); err != nil {
+ t.Fatalf("unmarshal failed: %v", err)
+ }
+ got := config.ObfsShare()
+ if got.Type != tc.wantType || got.Password != tc.wantPassword {
+ t.Fatalf("ObfsShare() = %+v, want type=%q password=%q", got, tc.wantType, tc.wantPassword)
+ }
+ })
+ }
+}
+
+func TestHysteria2ServerConfig_ParsesModernUpstreamSchema(t *testing.T) {
+ raw := `listen: 0.0.0.0:443
+tls:
+ cert: /etc/hysteria/server.crt
+ key: /etc/hysteria/server.key
+ clientCA: /etc/hysteria/client-ca.crt
+ech:
+ keyPath: /etc/hysteria/ech.pem
+obfs:
+ type: gecko
+ gecko:
+ password: p
+ minPacketSize: 512
+ maxPacketSize: 1200
+bandwidth:
+ up: 50 mbps
+ down: 50 mbps
+ disableLossCompensation: false
+congestion:
+ type: bbr
+ bbrProfile: standard
+quic:
+ disableStatelessReset: false
+mimic:
+ enabled: false
+ interface: eth0
+ xdpMode: skb
+realm:
+ stunServers:
+ - stun.example.com:3478
+ ipMode: dual
+ portMapping:
+ enabled: false
+ timeout: 10s
+masquerade:
+ type: proxy
+ proxy:
+ url: https://example.com
+ rewriteHost: true
+ insecure: false
+ xForwarded: true
+trafficStats:
+ listen: 127.0.0.1:36712
+`
+
+ var config bo.Hysteria2ServerConfig
+ if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
+ t.Fatalf("unmarshal failed: %v", err)
+ }
+
+ if config.ECH == nil || config.ECH.KeyPath == nil || *config.ECH.KeyPath != "/etc/hysteria/ech.pem" {
+ t.Fatal("ech.keyPath was not parsed")
+ }
+ if config.TLS == nil || config.TLS.ClientCA == nil || *config.TLS.ClientCA != "/etc/hysteria/client-ca.crt" {
+ t.Fatal("tls.clientCA was not parsed")
+ }
+ if config.Congestion == nil || config.Congestion.Type == nil || *config.Congestion.Type != "bbr" {
+ t.Fatal("congestion.type was not parsed")
+ }
+ if config.Congestion.BBRProfile == nil || *config.Congestion.BBRProfile != "standard" {
+ t.Fatal("congestion.bbrProfile was not parsed")
+ }
+ if config.Bandwidth == nil || config.Bandwidth.DisableLossCompensation == nil || *config.Bandwidth.DisableLossCompensation {
+ t.Fatal("bandwidth.disableLossCompensation was not parsed")
+ }
+ if config.QUIC == nil || config.QUIC.DisableStatelessReset == nil || *config.QUIC.DisableStatelessReset {
+ t.Fatal("quic.disableStatelessReset was not parsed")
+ }
+ if config.Mimic == nil || config.Mimic.Enabled == nil || *config.Mimic.Enabled {
+ t.Fatal("mimic section was not parsed")
+ }
+ if config.Realm == nil || len(config.Realm.StunServers) != 1 || config.Realm.PortMapping == nil {
+ t.Fatal("realm section was not parsed")
+ }
+ if config.Masquerade == nil || config.Masquerade.Proxy == nil ||
+ config.Masquerade.Proxy.XForwarded == nil || !*config.Masquerade.Proxy.XForwarded {
+ t.Fatal("masquerade.proxy.xForwarded was not parsed")
}
}
diff --git a/apps/service/hysteria2_export.go b/apps/service/hysteria2_export.go
new file mode 100644
index 0000000..2999415
--- /dev/null
+++ b/apps/service/hysteria2_export.go
@@ -0,0 +1,208 @@
+package service
+
+import (
+ "errors"
+ "net/url"
+ "os"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+
+ "hy2xs-admin/dao"
+ "hy2xs-admin/model/constant"
+)
+
+// RedactedPlaceholder — маркер вырезанного секрета в экспортируемом конфиге.
+const RedactedPlaceholder = ""
+
+// GetRawHysteria2Config возвращает исходный YAML серверного конфига без
+// прохода через типизированную модель.
+//
+// Это отдельный слой от GetHysteria2Config намеренно: типизированная модель
+// отражает известные HY2XS поля и используется для отображения, а сырой YAML
+// нужен там, где нельзя потерять поля, о которых HY2XS пока не знает.
+func GetRawHysteria2Config() (string, error) {
+ config, err := dao.GetConfig("key = ?", constant.Hysteria2Config)
+ if err != nil {
+ return "", err
+ }
+ if config.Value != nil && strings.TrimSpace(*config.Value) != "" {
+ return *config.Value, nil
+ }
+
+ content, readErr := os.ReadFile(constant.Hysteria2ConfigPath)
+ if readErr != nil {
+ return "", readErr
+ }
+ return string(content), nil
+}
+
+// ExportHysteria2ConfigYaml готовит серверный конфиг к выгрузке оператору.
+//
+// Гарантии:
+// 1. неизвестные upstream-поля сохраняются — будущие версии Hysteria не
+// обрезаются только потому, что HY2XS ещё не научился их показывать;
+// 2. секреты не покидают сервер в открытом виде.
+func ExportHysteria2ConfigYaml() ([]byte, error) {
+ raw, err := GetRawHysteria2Config()
+ if err != nil {
+ return nil, err
+ }
+ return SanitizeHysteria2ConfigYaml(raw)
+}
+
+// SanitizeHysteria2ConfigYaml вырезает секреты из YAML, сохраняя структуру и
+// все прочие поля документа.
+func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
+ if strings.TrimSpace(raw) == "" {
+ return nil, errors.New("hysteria2 config is empty")
+ }
+
+ var document yaml.Node
+ if err := yaml.Unmarshal([]byte(raw), &document); err != nil {
+ return nil, err
+ }
+
+ redactNode(&document, nil)
+
+ out, err := yaml.Marshal(&document)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// isSecretKey — обобщённое правило. Оно важно именно потому, что экспорт
+// сохраняет неизвестные поля: новое upstream-поле с секретом будет вырезано
+// ещё до того, как HY2XS про него узнает.
+func isSecretKey(key string) bool {
+ lowered := strings.ToLower(key)
+ for _, marker := range []string{"password", "passwd", "secret", "token", "credential"} {
+ if strings.Contains(lowered, marker) {
+ return true
+ }
+ }
+ return false
+}
+
+// isSecretMapPath — узлы, где секретом являются все значения карты, а не ключ.
+func isSecretMapPath(path []string) bool {
+ joined := strings.Join(path, ".")
+ switch joined {
+ case "auth.userpass", "acme.dns.config":
+ return true
+ }
+ return false
+}
+
+func redactNode(node *yaml.Node, path []string) {
+ if node == nil {
+ return
+ }
+
+ switch node.Kind {
+ case yaml.DocumentNode:
+ for _, child := range node.Content {
+ redactNode(child, path)
+ }
+
+ case yaml.SequenceNode:
+ for _, child := range node.Content {
+ // Индекс не попадает в путь: правила формулируются по именам полей.
+ redactNode(child, path)
+ }
+
+ case yaml.MappingNode:
+ if isSecretMapPath(path) {
+ redactAllScalarValues(node)
+ return
+ }
+ for i := 0; i+1 < len(node.Content); i += 2 {
+ key := node.Content[i].Value
+ value := node.Content[i+1]
+ childPath := append(append([]string{}, path...), key)
+
+ if isSecretKey(key) {
+ redactSubtree(value)
+ continue
+ }
+ if value.Kind == yaml.ScalarNode && looksLikeURLKey(key) {
+ value.Value = sanitizeURLValue(value.Value)
+ value.Tag = "!!str"
+ value.Style = 0
+ continue
+ }
+ redactNode(value, childPath)
+ }
+ }
+}
+
+func looksLikeURLKey(key string) bool {
+ lowered := strings.ToLower(key)
+ return lowered == "url" || lowered == "addr"
+}
+
+func redactSubtree(node *yaml.Node) {
+ if node == nil {
+ return
+ }
+ switch node.Kind {
+ case yaml.ScalarNode:
+ setRedacted(node)
+ case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode:
+ redactAllScalarValues(node)
+ }
+}
+
+func redactAllScalarValues(node *yaml.Node) {
+ switch node.Kind {
+ case yaml.MappingNode:
+ for i := 0; i+1 < len(node.Content); i += 2 {
+ redactSubtree(node.Content[i+1])
+ }
+ case yaml.SequenceNode, yaml.DocumentNode:
+ for _, child := range node.Content {
+ redactSubtree(child)
+ }
+ case yaml.ScalarNode:
+ setRedacted(node)
+ }
+}
+
+func setRedacted(node *yaml.Node) {
+ node.Value = RedactedPlaceholder
+ node.Tag = "!!str"
+ node.Style = 0
+}
+
+// sanitizeURLValue убирает из URL встроенные учётные данные и секретные
+// query-параметры, сохраняя остальную часть адреса читаемой.
+func sanitizeURLValue(raw string) string {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return raw
+ }
+
+ parsed, err := url.Parse(trimmed)
+ if err != nil || parsed.Scheme == "" {
+ return raw
+ }
+
+ if parsed.User != nil {
+ parsed.User = url.User(RedactedPlaceholder)
+ }
+
+ query := parsed.Query()
+ changed := false
+ for key := range query {
+ if isSecretKey(key) {
+ query.Set(key, RedactedPlaceholder)
+ changed = true
+ }
+ }
+ if changed {
+ parsed.RawQuery = query.Encode()
+ }
+
+ return parsed.String()
+}
diff --git a/apps/service/hysteria2_export_test.go b/apps/service/hysteria2_export_test.go
new file mode 100644
index 0000000..b49565d
--- /dev/null
+++ b/apps/service/hysteria2_export_test.go
@@ -0,0 +1,247 @@
+package service
+
+import (
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+const exportSampleConfig = `listen: 0.0.0.0:443
+
+acme:
+ domains:
+ - vpn.example.com
+ email: admin@example.com
+ ca: letsencrypt
+ dir: /var/lib/hysteria/acme
+ listenHost: 0.0.0.0
+ type: http
+ dns:
+ name: cloudflare
+ config:
+ cloudflare_api_token: super-secret-token
+ zone: example.com
+
+auth:
+ type: http
+ http:
+ url: http://127.0.0.1:8080/hui/hysteria2/auth?access_token=machine-secret
+ insecure: false
+ userpass:
+ alice: alice-password
+ bob: bob-password
+
+obfs:
+ type: gecko
+ gecko:
+ password: gecko-obfs-secret
+ minPacketSize: 512
+ maxPacketSize: 1200
+
+bandwidth:
+ up: 50 mbps
+ down: 50 mbps
+ disableLossCompensation: false
+
+congestion:
+ type: bbr
+ bbrProfile: standard
+
+trafficStats:
+ listen: 127.0.0.1:36712
+ secret: traffic-stats-secret
+
+outbounds:
+ - name: upstream
+ type: socks5
+ socks5:
+ addr: 10.0.0.1:1080
+ username: proxyuser
+ password: proxy-password
+
+quic:
+ initStreamReceiveWindow: 8388608
+ disableStatelessReset: false
+
+someFutureUpstreamFeature:
+ enabled: true
+ nested:
+ tuning: 42
+ list:
+ - a
+ - b
+`
+
+func sanitizeForTest(t *testing.T, raw string) string {
+ t.Helper()
+ out, err := SanitizeHysteria2ConfigYaml(raw)
+ if err != nil {
+ t.Fatalf("sanitize failed: %v", err)
+ }
+ return string(out)
+}
+
+func TestSanitizeHysteria2ConfigYaml_RemovesSecrets(t *testing.T) {
+ sanitized := sanitizeForTest(t, exportSampleConfig)
+
+ leaked := []string{
+ "gecko-obfs-secret",
+ "traffic-stats-secret",
+ "machine-secret",
+ "alice-password",
+ "bob-password",
+ "proxy-password",
+ "super-secret-token",
+ }
+ for _, secret := range leaked {
+ if strings.Contains(sanitized, secret) {
+ t.Fatalf("exported config leaks secret %q:\n%s", secret, sanitized)
+ }
+ }
+}
+
+func TestSanitizeHysteria2ConfigYaml_PreservesUnknownUpstreamFields(t *testing.T) {
+ sanitized := sanitizeForTest(t, exportSampleConfig)
+
+ var parsed map[string]any
+ if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
+ t.Fatalf("sanitized output is not valid yaml: %v", err)
+ }
+
+ future, ok := parsed["someFutureUpstreamFeature"].(map[string]any)
+ if !ok {
+ t.Fatalf("unknown upstream section was dropped:\n%s", sanitized)
+ }
+ if future["enabled"] != true {
+ t.Fatalf("unknown upstream scalar was dropped: %+v", future)
+ }
+ nested, ok := future["nested"].(map[string]any)
+ if !ok {
+ t.Fatalf("nested unknown section was dropped: %+v", future)
+ }
+ if nested["tuning"] != 42 {
+ t.Fatalf("nested unknown value was dropped: %+v", nested)
+ }
+ if list, ok := nested["list"].([]any); !ok || len(list) != 2 {
+ t.Fatalf("nested unknown list was dropped: %+v", nested)
+ }
+}
+
+func TestSanitizeHysteria2ConfigYaml_KeepsNonSecretOperationalFields(t *testing.T) {
+ sanitized := sanitizeForTest(t, exportSampleConfig)
+
+ kept := []string{
+ "listen: 0.0.0.0:443",
+ "vpn.example.com",
+ "type: gecko",
+ "minPacketSize: 512",
+ "maxPacketSize: 1200",
+ "bbrProfile: standard",
+ "disableLossCompensation: false",
+ "disableStatelessReset: false",
+ "127.0.0.1:36712",
+ }
+ for _, fragment := range kept {
+ if !strings.Contains(sanitized, fragment) {
+ t.Fatalf("exported config lost operational field %q:\n%s", fragment, sanitized)
+ }
+ }
+}
+
+func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testing.T) {
+ sanitized := sanitizeForTest(t, exportSampleConfig)
+
+ if !strings.Contains(sanitized, "127.0.0.1:8080/hui/hysteria2/auth") {
+ t.Fatalf("auth url shape was lost:\n%s", sanitized)
+ }
+ if !strings.Contains(sanitized, "access_token="+RedactedPlaceholder) &&
+ !strings.Contains(sanitized, "access_token=%3Credacted%3E") {
+ t.Fatalf("access_token was not redacted:\n%s", sanitized)
+ }
+}
+
+func TestSanitizeURLValue(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ mustKeep []string
+ mustRemove []string
+ }{
+ {
+ name: "strips access token",
+ in: "http://127.0.0.1:8080/hui/hysteria2/auth?access_token=abc123",
+ mustKeep: []string{"127.0.0.1:8080", "/hui/hysteria2/auth"},
+ mustRemove: []string{"abc123"},
+ },
+ {
+ name: "strips embedded credentials",
+ in: "https://user:p4ssw0rd@proxy.example.com:8443/path",
+ mustKeep: []string{"proxy.example.com:8443", "/path"},
+ mustRemove: []string{"p4ssw0rd"},
+ },
+ {
+ name: "leaves clean url untouched",
+ in: "https://example.com/masq",
+ mustKeep: []string{"https://example.com/masq"},
+ },
+ {
+ name: "leaves plain host:port untouched",
+ in: "10.0.0.1:1080",
+ mustKeep: []string{"10.0.0.1:1080"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := sanitizeURLValue(tc.in)
+ for _, fragment := range tc.mustKeep {
+ if !strings.Contains(got, fragment) {
+ t.Fatalf("sanitizeURLValue(%q) = %q, expected to keep %q", tc.in, got, fragment)
+ }
+ }
+ for _, fragment := range tc.mustRemove {
+ if strings.Contains(got, fragment) {
+ t.Fatalf("sanitizeURLValue(%q) = %q, expected to remove %q", tc.in, got, fragment)
+ }
+ }
+ })
+ }
+}
+
+func TestIsSecretKey(t *testing.T) {
+ secret := []string{"password", "Password", "obfs_password", "secret", "trafficSecret", "access_token", "apiToken", "credentials"}
+ for _, key := range secret {
+ if !isSecretKey(key) {
+ t.Fatalf("expected %q to be treated as secret", key)
+ }
+ }
+
+ // Пути к файлам не являются секретами и должны остаться читаемыми.
+ notSecret := []string{"key", "keyPath", "cert", "clientCA", "listen", "url", "type", "dir"}
+ for _, key := range notSecret {
+ if isSecretKey(key) {
+ t.Fatalf("expected %q to stay visible in export", key)
+ }
+ }
+}
+
+func TestSanitizeHysteria2ConfigYaml_RejectsEmptyInput(t *testing.T) {
+ if _, err := SanitizeHysteria2ConfigYaml(" \n"); err == nil {
+ t.Fatal("expected an error for empty config")
+ }
+}
+
+func TestSanitizeHysteria2ConfigYaml_RedactsUnknownFutureSecretField(t *testing.T) {
+ // Обратная сторона сохранения неизвестных полей: новое upstream-поле с
+ // секретом должно вырезаться до того, как HY2XS про него узнает.
+ raw := "listen: 0.0.0.0:443\nfutureFeature:\n apiSecret: leak-me\n nested:\n userPassword: leak-me-too\n"
+ sanitized := sanitizeForTest(t, raw)
+
+ if strings.Contains(sanitized, "leak-me") {
+ t.Fatalf("unknown future secret field leaked:\n%s", sanitized)
+ }
+ if !strings.Contains(sanitized, "futureFeature") {
+ t.Fatalf("unknown future section was dropped:\n%s", sanitized)
+ }
+}
diff --git a/docs/01-architecture-baseline.md b/docs/01-architecture-baseline.md
index 19f20a8..4ee26b5 100644
--- a/docs/01-architecture-baseline.md
+++ b/docs/01-architecture-baseline.md
@@ -41,9 +41,10 @@ Target layer **не содержит сборщика** и **не выполня
- **Hysteria2**
- QUIC/UDP
- один фиксированный UDP-порт
-- `Salamander` включён по умолчанию
+- `Gecko` включён по умолчанию, `Salamander` доступен как режим совместимости
- IPv4-only
- лимит по умолчанию: 50/50 Mbps на клиента
+- fallback congestion controller: BBR (профиль `standard`)
### UI слой
- **HY2XS admin** — штатный компонент проекта
@@ -65,7 +66,7 @@ Target layer **не содержит сборщика** и **не выполня
## Принципы
### 1. Ядро, UI и оркестратор ведут себя по-разному
-- Hysteria2: берём свежую upstream-версию при установке
+- Hysteria2: последнюю стабильную upstream-версию выбирает **сборка пакета**, установка ставит уже замороженный артефакт
- HY2XS admin: разрабатываем **внутри проекта** и поставляем его сами
- Оркестратор: пишем на **Bun + TypeScript**, но собираем **локально**, а не на target
diff --git a/docs/02-build-layer-and-package.md b/docs/02-build-layer-and-package.md
index ec72bd1..c807ef4 100644
--- a/docs/02-build-layer-and-package.md
+++ b/docs/02-build-layer-and-package.md
@@ -43,13 +43,17 @@ Builder не является частью target install flow: на target serv
## Что делает builder
1. Проверяет структуру проекта.
-2. Собирает / подготавливает HY2XS admin.
-3. Компилирует оркестратор из Bun/TypeScript в install-артефакт.
-4. Копирует артефакты UI в package staging directory.
-5. Кладёт entrypoint, templates, docs и service files.
-6. Формирует итоговый install package.
-7. При необходимости считает manifest/checksum.
-8. Выдаёт один переносимый результат для target machine.
+2. Прогоняет тесты и типы оркестратора.
+3. Разрешает upstream-версию Hysteria и проходит compatibility gate.
+4. Компилирует оркестратор из Bun/TypeScript в install-артефакт.
+5. Собирает / подготавливает HY2XS admin.
+6. Прогоняет тесты HY2XS admin (после сборки frontend: `go:embed all:dist` требует готовых ассетов).
+7. Копирует артефакты UI в package staging directory.
+8. Кладёт entrypoint, templates, docs и service files.
+9. Формирует итоговый install package.
+10. Считает manifest/checksum.
+11. Проверяет архив и прогоняет acceptance-проверки.
+12. Выдаёт один переносимый результат для target machine.
## Что builder не делает
@@ -119,14 +123,76 @@ project/
Сам бинарь Hysteria2 **не вендорится** в install package как baseline-правило.
Причина:
-- ядро Hysteria рассматривается как stable upstream component
-- целевая установка должна брать его с official upstream на момент развёртывания
+- ядро Hysteria рассматривается как stable upstream component;
+- целевая установка скачивает его с official upstream, но **строго по замороженным координатам**.
+
+### Разрешение версии на сборке
+
+```text
+SOURCE
+ │
+ ▼
+resolve latest stable (HyNetworks/hysteria, только теги app/vX.Y.Z)
+ │
+ ▼
+resolve exact release asset (hysteria-linux-amd64)
+ │
+ ▼
+download + compute SHA-256
+ │
+ ▼
+compatibility gate (реальный бинарник принимает канонический конфиг HY2XS)
+ │
+ ▼
+PACKAGE METADATA
+ version = vX.Y.Z
+ exact_url =
+ sha256 = <...>
+ resolution = latest-stable | pinned | override
+ │
+ ▼
+TARGET SERVER
+ скачивает уже конкретный неизменяемый артефакт
+```
+
+Так одновременно выполняются оба требования: «по умолчанию брать последнюю стабильную» и «production-установка должна быть детерминированной и проверяемой».
+
+Переменные builder:
+
+| Переменная | Значение по умолчанию | Назначение |
+| --- | --- | --- |
+| `HYSTERIA_CHANNEL` | `stable` | `stable` — разрешить последнюю стабильную через upstream API; `pinned` — взять `tools/build/hysteria-lock.env` без сети |
+| `HYSTERIA_VERSION_OVERRIDE` | пусто | Закрепить конкретную версию `vX.Y.Z` |
+| `HYSTERIA_COMPAT_GATE` | `true` | Compatibility gate; для release-сборок обязателен |
+| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в `tools/build/hysteria-lock.env` |
+| `GITHUB_TOKEN` | пусто | Опционально, чтобы не упереться в anonymous rate limit |
Дополнительно:
-- версия Hysteria2 фиксируется в metadata install package (`metadata/hysteria.version`);
-- URL артефакта и SHA256 фиксируются в metadata install package (`metadata/hysteria.url`, `metadata/hysteria.sha256`);
+- версия, URL и SHA256 фиксируются в metadata install package (`metadata/hysteria.version`, `metadata/hysteria.url`, `metadata/hysteria.sha256`);
+- способ выбора версии фиксируется в `metadata/hysteria.resolution` и `metadata/package.env`;
- runtime `reconfigure` не обновляет и не откатывает бинарник Hysteria2;
-- install flow валидирует SHA256 и фактическую версию установленного бинарника.
+- install flow валидирует SHA256 и фактическую версию установленного бинарника;
+- install-time код не обращается к upstream API и не использует moving `latest` — это проверяется тестами и acceptance-шагом сборки.
+
+### Compatibility gate
+
+Gate защищает от ситуации, когда upstream меняет схему конфигурации, а builder молча собирает неработающий HY2XS.
+
+Порядок:
+
+1. скачать артефакт и сверить SHA-256;
+2. сверить `hysteria version` с разрешённой версией;
+3. отрендерить канонический конфиг HY2XS тем же кодом, что работает на target (`orchestrator/tools/render-canonical-config.ts`);
+4. запустить реальный бинарник Hysteria с этим конфигом — для Gecko и для Salamander;
+5. только после этого собирать release package.
+
+При несовместимости ломается сборка:
+
+```text
+BUILD FAILED: unsupported Hysteria stable v2.13.0
+```
+
+Это осознанно: ошибка должна проявиться на build machine, а не на сервере оператора.
## Инварианты
@@ -137,4 +203,6 @@ project/
3. на сервере нет отдельного build step
4. bundled UI уже находится внутри пакета
5. оркестратор authored as Bun/TypeScript, но на target приходит как готовый install-артефакт
-6. Hysteria2 подтягивается install layer'ом с upstream, а не собирается на target из исходников
+6. Hysteria2 подтягивается install layer'ом с upstream по замороженным координатам, а не собирается на target из исходников
+7. выход новой версии Hysteria после сборки не меняет содержимое уже собранного пакета
+8. несовместимый upstream ломает сборку, а не установку у пользователя
diff --git a/docs/03-server-hysteria2.md b/docs/03-server-hysteria2.md
index a3c9012..893dea6 100644
--- a/docs/03-server-hysteria2.md
+++ b/docs/03-server-hysteria2.md
@@ -18,13 +18,53 @@ Hysteria2 — основной транспортный компонент се
## Версионная политика
-С учётом выбранной модели «берём свежее из upstream» фиксируется такая практика:
+Ключевое правило: **«последняя стабильная» определяется на этапе сборки пакета, а не на целевом сервере.**
-- по умолчанию install layer тянет **свежий upstream release / install source**
-- фактически установленная версия обязательно записывается в `post-install.env`
-- бинарник Hysteria2 устанавливается только на этапе `install`
-- версия, URL и SHA256 фиксируются в metadata install package
-- `reconfigure` не обновляет и не откатывает бинарник Hysteria2
+Не «HY2XS использует Hysteria vX.Y.Z», а:
+
+> HY2XS по умолчанию берёт последний стабильный релиз Hysteria, доступный на момент сборки пакета. Разрешённая версия, URL артефакта и контрольная сумма замораживаются в получившемся install package.
+
+Практика:
+
+- builder обращается к каноническому upstream `HyNetworks/hysteria`;
+- принимаются только стабильные релизы с тегом вида `app/vX.Y.Z`, без draft и prerelease;
+- берётся ровно один артефакт `hysteria-linux-amd64`, URL используется в том виде, в каком его отдал upstream API;
+- SHA-256 считается локально от скачанного артефакта, а не берётся из стороннего файла;
+- версия, URL и SHA-256 фиксируются в metadata install package;
+- на target-сервере **никогда** не используется moving `latest`;
+- бинарник Hysteria2 устанавливается только на этапе `install`;
+- фактически установленная версия записывается в `post-install.env`;
+- `reconfigure` не обновляет и не откатывает бинарник Hysteria2.
+
+Следствие: если между сборкой пакета и его установкой выйдет новая версия Hysteria, содержимое установки **не изменится под ногами**. Повторная установка старого пакета поставит ту же версию, что и в день сборки.
+
+Переопределения builder:
+
+```bash
+HYSTERIA_CHANNEL=stable # по умолчанию: разрешить последнюю стабильную
+HYSTERIA_CHANNEL=pinned # взять закоммиченный tools/build/hysteria-lock.env, без сети
+HYSTERIA_VERSION_OVERRIDE=v2.12.2 # закрепить конкретную версию
+```
+
+## Compatibility gate
+
+Автоматический выбор «последней стабильной» без проверки опасен: upstream может изменить схему конфигурации, и builder молча соберёт неработающий HY2XS.
+
+Поэтому до создания release-пакета builder:
+
+1. скачивает артефакт и сверяет SHA-256;
+2. сверяет `hysteria version` с разрешённой версией;
+3. рендерит канонический конфиг HY2XS **тем же кодом**, который работает на target-сервере;
+4. запускает реальный бинарник Hysteria с этим конфигом — отдельно для Gecko и для Salamander;
+5. только после этого формирует пакет.
+
+Если upstream несовместим, ломается **сборка**:
+
+```text
+BUILD FAILED: unsupported Hysteria stable v2.13.0
+```
+
+а не production-сервер оператора.
## Платформа
@@ -48,14 +88,55 @@ Hysteria2 — основной транспортный компонент се
## Обфускация
-В baseline включается:
-- `obfs.type: salamander`
-- `obfs.password`
+Новые установки HY2XS используют **Gecko**.
+
+Gecko помечен upstream как **experimental**. Он достраивается поверх Salamander: помимо scramble он дополнительно фрагментирует QUIC handshake на пакеты случайного размера. HY2XS использует upstream-defaults размеров пакетов `512/1200` как проверенный production-профиль.
+
+**Salamander остаётся полностью поддержанным режимом совместимости.** Смена типа обфускации требует соответствующих изменений на клиенте: это изменение wire-совместимости, а не косметическая настройка.
+
+Baseline:
+
+```yaml
+obfs:
+ type: gecko
+ gecko:
+ password: "<сгенерированный пароль>"
+ minPacketSize: 512
+ maxPacketSize: 1200
+```
+
+Режим совместимости:
+
+```yaml
+obfs:
+ type: salamander
+ salamander:
+ password: "<сгенерированный пароль>"
+```
Правила:
-- пароль должен быть сильным
-- пароль должен фиксироваться в конфигурационном контуре
-- значение должно быть доступно оператору через runtime config и `post-install.env`
+- тип выбирается через `HY2XS_HYSTERIA_OBFS_TYPE` (`gecko` | `salamander`);
+- пароль должен быть сильным, генерируется автоматически при `__GENERATE__` или пустом значении;
+- пароль фиксируется в конфигурационном контуре и доступен оператору через runtime config и `post-install.env`;
+- `obfs`-блок формируется оркестратором целиком, а не собирается из отдельных placeholders внутри YAML — комбинация вида `type: gecko` рядом с блоком `salamander` структурно невозможна.
+
+### Почему размеры пакетов Gecko не вынесены в env
+
+Официальная схема `hysteria2://` передаёт только тип обфускации и пароль. `minPacketSize` и `maxPacketSize` в ссылку не помещаются.
+
+Если разрешить оператору произвольные значения, сгенерированная клиентская ссылка перестанет полностью описывать подключение и потребуется отдельный формат — выгружаемый клиентский профиль. Пока такой задачи нет, фиксация `512/1200` даёт корректную ссылку и воспроизводимое поведение.
+
+Валидация (на случай будущего расширения) централизована в оркестраторе: `min > 0`, `max >= min`, `max <= 2048`.
+
+## Версия схемы конфигурации
+
+```bash
+HY2XS_CONFIG_SCHEMA_VERSION=2
+```
+
+Пакет понимает только свою версию схемы. Конфигурация с другой версией отклоняется fail-fast, а не применяется частично.
+
+HY2XS `v1` **не мигрирует установки `0.x` на месте**: между `0.x` и `1.0.0` изменились схема конфигурации, тип обфускации по умолчанию и контракт выбора версии Hysteria. Переход выполняется чистой установкой.
## TLS
@@ -72,8 +153,9 @@ Hysteria2 — основной транспортный компонент се
- `acme` block обязан содержать `type: http|tls` из runtime env (`HY2XS_ACME_TYPE`);
- `HY2XS_ACME_TYPE=dns` в production-профиле запрещён до отдельной реализации;
- `HY2XS_HYSTERIA_AUTH_MODE` зафиксирован в `http` и валидируется fail-fast;
-- `HY2XS_HYSTERIA_OBFS_TYPE` зафиксирован в `salamander` и валидируется fail-fast;
-- блок `masquerade` в baseline не задаётся (допустимо, но приводит к `404 Not Found` на обычный HTTP трафик);
+- `HY2XS_HYSTERIA_OBFS_TYPE` принимает `gecko` (default) или `salamander` и валидируется fail-fast;
+- блок `masquerade` в baseline не задаётся: при включённой обфускации сервер и так перестаёт быть обычным HTTP/3 endpoint, поэтому masquerade не даёт выигрыша, а `404 Not Found` на обычный HTTP-трафик — ожидаемое поведение;
+- `ech` в baseline не включается: при включённой обфускации соединение целиком перестаёт выглядеть как обычный QUIC, поэтому ECH не даёт дополнительной выгоды (он полезен в bare-режиме);
- `file` -> только `tls.cert`/`tls.key` block;
- `self_signed_dev` -> только dev сценарии.
@@ -91,11 +173,55 @@ Hysteria2 — основной транспортный компонент се
Серверная baseline policy:
- `bandwidth.up = 50 mbps`
- `bandwidth.down = 50 mbps`
+- `bandwidth.disableLossCompensation = false`
- `ignoreClientBandwidth = false`
+- `congestion.type = bbr`
+- `congestion.bbrProfile = standard`
Важно:
-- эти параметры сами по себе не исчерпывают speed policy
-- корректный лимит ожидается только в паре с совместимым клиентским конфигом
+- эти параметры сами по себе не исчерпывают speed policy;
+- корректный лимит ожидается только в паре с совместимым клиентским конфигом;
+- `congestion` — это **fallback** controller: он применяется, когда Brutal bandwidth не согласован сторонами. Подробнее — в [06-speed-limits-and-congestion.md](06-speed-limits-and-congestion.md).
+
+## QUIC stateless reset
+
+```yaml
+quic:
+ disableStatelessReset: false
+```
+
+Начиная с Hysteria 2.12.1 сервер отправляет stateless reset, чтобы клиент со stale-соединением после перезапуска сервера или сна устройства переподключался сразу, а не по таймауту. В 2.12.2 появилась возможность это отключить.
+
+Для VPN-подобного применения HY2XS быстрый reconnect — плюс, поэтому механизм остаётся включённым, а значение фиксируется в конфиге явно.
+
+## Возможности вне default-профиля
+
+HY2XS обязан **понимать** современную схему Hysteria, но не обязан включать всё подряд. Разделяются три уровня:
+
+| Возможность | Генерирует HY2XS | Читает и сохраняет | Отдельный профиль |
+| --- | :-: | :-: | :-: |
+| Gecko | да | да | — |
+| Salamander | fallback | да | — |
+| BBR / bbrProfile | да | да | — |
+| Loss compensation | да | да | — |
+| QUIC stateless reset | да | да | — |
+| ECH | нет | да | позже |
+| Mimic | нет | да | позже |
+| Realms | нет | да | позже |
+| Port hopping | нет | да | позже |
+| ACME DNS | нет | да | позже |
+| Masquerade | нет | да | позже |
+
+Причина не в качестве этих возможностей, а в том, что каждая меняет соседнюю подсистему:
+
+- **Mimic** — привилегии, eBPF/XDP, сторонний бинарник, требования к клиенту; текущий systemd-контракт намеренно запускает Hysteria под непривилегированным пользователем с `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`, поэтому Mimic несовместим с ним по построению и требует отдельного security-профиля;
+- **Realms** — сетевая топология (STUN/hole punching вместо публичного IPv4 и own nftables);
+- **Port hopping** — nftables и capabilities; официально несовместим с Mimic;
+- **ECH** — жизненный цикл ключей и распространение конфигурации клиентам (Hysteria не генерирует ECH keypair сама);
+- **ACME DNS** — учётные данные провайдера и работа с секретами;
+- **Masquerade** — дополнительное web/proxy-поведение.
+
+Ни одна из них не должна включаться toggle'ом, который незаметно меняет systemd capabilities или топологию firewall.
## Рекомендуемые пути
@@ -108,8 +234,8 @@ Hysteria2 — основной транспортный компонент се
После установки должно быть верно:
-1. Hysteria2 получена из official upstream
-2. фактическая версия отражена в `post-install.env`
+1. Hysteria2 получена из official upstream по замороженному в пакете URL и SHA-256
+2. фактическая версия совпадает с версией из metadata пакета и отражена в `post-install.env`
3. конфиг валиден
4. сервис стартует через systemd
5. нужный UDP-порт реально слушается
@@ -118,3 +244,5 @@ Hysteria2 — основной транспортный компонент се
8. `trafficStats.secret` отдельный от `JWT_SECRET`
9. IPv6 listen не используется
10. публичные клиентские endpoint/URL берутся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen`/request-host
+11. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации и пароль, и совместимый клиент подключается по ней напрямую
+12. SNI в ссылке берётся из ACME-домена, затем из `HY2XS_DOMAIN`, затем из `HY2XS_PUBLIC_HOST`; IP-адрес как SNI не используется
diff --git a/docs/04-admin-panel.md b/docs/04-admin-panel.md
index a8c3443..583e435 100644
--- a/docs/04-admin-panel.md
+++ b/docs/04-admin-panel.md
@@ -51,6 +51,54 @@ Target server **не собирает** admin-компонент из исход
HY2XS admin работает как надстройка над Hysteria YAML/API-слоем. Это нормально: важно только, чтобы источник истины по runtime-состоянию был понятен и не было двух конкурирующих конфигурационных миров без правил синхронизации.
+Относительно конфигурации Hysteria панель **read-only**: конфиг генерирует оркестратор.
+
+## Два слоя работы с конфигом Hysteria
+
+Это важное архитектурное разделение.
+
+| Слой | Назначение | Поведение при неизвестных полях |
+| --- | --- | --- |
+| Типизированная модель | отображение известных HY2XS полей в UI | неизвестные поля не отображаются |
+| Сырой YAML | экспорт и сохранение | неизвестные поля **сохраняются** |
+
+Причина: если бы экспорт работал через типизированную модель (`Unmarshal` → структура → `Marshal`), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
+
+Поэтому:
+
+- экспорт читает исходный YAML и сохраняет структуру документа целиком;
+- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
+- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
+
+### Санитайз экспорта
+
+Экспортируемый файл покидает сервер, поэтому секреты из него вырезаются:
+
+- пароли обфускации (`obfs.*.password`);
+- `trafficStats.secret`;
+- `access_token` в auth-URL и учётные данные, встроенные в URL;
+- `auth.password`, `auth.userpass`;
+- учётные данные ACME DNS-провайдера;
+- любые **неизвестные** поля, имя которых содержит `password`, `secret`, `token` или `credential`.
+
+Последний пункт — обратная сторона сохранения неизвестных полей: новое upstream-поле с секретом вырезается ещё до того, как HY2XS про него узнает.
+
+Пути к файлам (`tls.cert`, `tls.key`, `ech.keyPath`, `tls.clientCA`) секретами не считаются и остаются читаемыми — они нужны для диагностики.
+
+## Модель современной схемы Hysteria
+
+Модель админки понимает актуальную серверную схему, даже там, где UI не позволяет ничего включить: `obfs.gecko`, `ech`, `congestion`, `mimic`, `realm`, `tls.clientCA`, `quic.disableStatelessReset`, `bandwidth.disableLossCompensation`, `masquerade.proxy.xForwarded`.
+
+Смысл в том, чтобы admin **понимал текущую upstream-схему**, а не считал неизвестными поля собственного конфига.
+
+## Генерация клиентских ссылок
+
+- тип обфускации и пароль берутся из фактического конфига одинаково для всех поддерживаемых типов (`gecko`, `salamander`);
+- неизвестный тип обфускации в ссылку не попадает: лучше отсутствие параметра, чем параметр, который клиент не понимает;
+- публичный endpoint берётся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen` или Host-заголовка запроса;
+- SNI берётся из ACME-домена, затем из `HY2XS_DOMAIN`, затем из `HY2XS_PUBLIC_HOST`; IP-адрес как SNI не используется;
+- `minPacketSize`/`maxPacketSize` Gecko в ссылку не помещаются — поэтому HY2XS держит их на upstream-defaults `512/1200`.
+
## Правила ответственности
### Source of truth
@@ -81,7 +129,9 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP
- склеивать unit Hysteria2 и unit HY2XS admin в один сервис;
- раздувать оркестратор из-за особенностей панели;
- использовать HY2XS admin как updater бинаря Hysteria2;
-- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API.
+- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API;
+- экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
+- выгружать конфиг с секретами в открытом виде.
## Что фиксировать в `post-install.env`
@@ -107,3 +157,6 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP
5. Hysteria2 остаётся внешним vanilla upstream-компонентом
6. HY2XS admin не выступает updater-менеджером Hysteria2
7. `trafficStats.secret` не связан с `JWT_SECRET`
+8. экспорт конфига сохраняет неизвестные upstream-поля
+9. экспорт конфига не содержит секретов
+10. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую
diff --git a/docs/06-speed-limits-and-congestion.md b/docs/06-speed-limits-and-congestion.md
index 63bfd07..a646417 100644
--- a/docs/06-speed-limits-and-congestion.md
+++ b/docs/06-speed-limits-and-congestion.md
@@ -13,12 +13,27 @@
Это неверная модель.
+## Три разные вещи, которые нельзя смешивать
+
+Это главный источник путаницы в теме скоростей Hysteria.
+
+| Механизм | Что это | Где задаётся |
+| --- | --- | --- |
+| **Политика HY2XS 50/50** | продуктовое решение проекта, сколько давать клиенту | `HY2XS_HYSTERIA_BANDWIDTH_UP` / `_DOWN` |
+| **Brutal bandwidth** | режим Hysteria, работающий по согласованным сторонами значениям полосы | `bandwidth.up` / `bandwidth.down` на сервере + hints на клиенте |
+| **Fallback congestion controller** | что делает Hysteria, когда Brutal не применяется | `congestion.type` / `congestion.bbrProfile` |
+
+`50 mbps` здесь — **не** «оптимальная скорость Hysteria» и не свойство протокола. Это политика HY2XS.
+
## Что зафиксировано в baseline
### На сервере
- `bandwidth.up = 50 mbps`
- `bandwidth.down = 50 mbps`
+- `bandwidth.disableLossCompensation = false`
- `ignoreClientBandwidth = false`
+- `congestion.type = bbr`
+- `congestion.bbrProfile = standard`
### На клиенте
Совместимый клиентский конфиг должен задавать соответствующие bandwidth hints:
@@ -29,13 +44,30 @@
Ожидаемый 50/50 Mbps contract считается корректным только тогда, когда сервер и клиентская конфигурация согласованы.
+Логика выбора внутри Hysteria:
+
+- когда стороны согласовали Brutal bandwidth — используется Brutal;
+- когда это не применяется — используется выбранный fallback congestion controller.
+
+Поэтому BBR тоже является частью явного baseline HY2XS, а не «настройкой по умолчанию, о которой можно не думать».
+
+## Loss compensation
+
+```yaml
+bandwidth:
+ disableLossCompensation: false
+```
+
+Компенсация потерь (появилась в Hysteria 2.10.0) позволяет отправлять быстрее заданной полосы, чтобы компенсировать потерю пакетов. В baseline HY2XS она **включена**, а значение фиксируется в конфиге явно — проект про воспроизводимое поведение, а не про молчаливое следование upstream-дефолтам.
+
## Что делать с host-level BBR
`net.ipv4.tcp_congestion_control=bbr` можно оставить как общий системный тюнинг, но:
-- это не главный механизм speed policy Hysteria2
-- это не замена клиентским bandwidth hints
-- это не центр документации по лимитам
+- это не главный механизм speed policy Hysteria2;
+- это не замена клиентским bandwidth hints;
+- это **не то же самое**, что `congestion.type: bbr` в конфиге Hysteria — у Hysteria собственный congestion-control контур поверх QUIC;
+- это не центр документации по лимитам.
## Что фиксировать в `post-install.env`
@@ -43,8 +75,11 @@
- `HY2_BANDWIDTH_UP`
- `HY2_BANDWIDTH_DOWN`
- `HY2_IGNORE_CLIENT_BANDWIDTH`
+- `HY2_DISABLE_LOSS_COMPENSATION`
+- `HY2_CONGESTION_TYPE`
+- `HY2_BBR_PROFILE`
-Дополнительно фиксируется `HY2_VERSION` как фактически установленная версия Hysteria2.
+Дополнительно фиксируется `HY2_VERSION` как фактически установленная версия Hysteria2 и `HY2_RESOLUTION` как способ её выбора при сборке пакета.
## Что нельзя писать в проектных доках
@@ -52,6 +87,8 @@
- «лимит задаётся только на сервере, клиент не важен»
- «любой URI достаточно для полной speed policy»
- «host BBR и есть логика Hysteria»
+- «50 mbps — оптимальная скорость Hysteria» (это политика HY2XS, а не свойство протокола)
+- «Brutal и congestion controller — одно и то же»
## Правильная baseline-формулировка
diff --git a/docs/09-post-install-env.md b/docs/09-post-install-env.md
index d4dae20..5f9b1ab 100644
--- a/docs/09-post-install-env.md
+++ b/docs/09-post-install-env.md
@@ -59,6 +59,7 @@
- `ORCH_ENTRYPOINT`
### Общие
+- `HY2XS_CONFIG_SCHEMA_VERSION`
- `DEPLOY_DOMAIN`
- `PUBLIC_HOST`
- `PUBLIC_PORT`
@@ -71,7 +72,8 @@
### Hysteria
- `HY2_SOURCE=official-upstream`
-- `HY2_VERSION`
+- `HY2_VERSION` — фактически установленная версия
+- `HY2_RESOLUTION` — как версия была выбрана при сборке: `latest-stable`, `pinned` или `override`
- `HY2_TLS_MODE`
- `HY2_ACME_EMAIL`
- `HY2_TLS_CERT_PATH`
@@ -81,11 +83,17 @@
- `HY2_AUTH_MODE`
- `HY2_AUTH_URL`
- `HY2_TRAFFIC_STATS_LISTEN`
-- `HY2_OBFS_TYPE`
+- `HY2_OBFS_TYPE` — `gecko` или `salamander`
- `HY2_OBFS_PASSWORD`
+- `HY2_GECKO_MIN_PACKET_SIZE`
+- `HY2_GECKO_MAX_PACKET_SIZE`
- `HY2_BANDWIDTH_UP`
- `HY2_BANDWIDTH_DOWN`
+- `HY2_DISABLE_LOSS_COMPENSATION`
- `HY2_IGNORE_CLIENT_BANDWIDTH`
+- `HY2_CONGESTION_TYPE`
+- `HY2_BBR_PROFILE`
+- `HY2_DISABLE_STATELESS_RESET`
- `HY2_CONFIG_PATH`
### HY2XS admin
diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md
index f74110a..93d6f66 100644
--- a/docs/11-testing-and-acceptance.md
+++ b/docs/11-testing-and-acceptance.md
@@ -4,6 +4,24 @@
Зафиксировать checklist для новой двухслойной схемы.
+## Как запускать тесты
+
+```bash
+# Юнит-тесты и типы оркестратора
+cd orchestrator && bun install --frozen-lockfile && bun run check && bun test
+
+# Тесты и статический анализ HY2XS admin
+cd apps && go vet ./... && go test ./...
+
+# Полный E2E с реальным клиентом Hysteria (Debian 13 amd64)
+HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
+
+# Production-сборка: прогоняет тесты, резолвер и compatibility gate
+./tools/build/build.sh
+```
+
+`build.sh` останавливается, если падают тесты оркестратора, тесты админки или compatibility gate.
+
## A. Builder layer tests
### Проверяем
@@ -16,6 +34,81 @@
7. builder сам доставляет отсутствующие build-зависимости
8. builder проверяет версии Go/Bun/Node.js/pnpm
9. builder пишет версии toolchain в metadata
+10. builder прогоняет `bun test` и `go test` до упаковки
+
+## A1. Latest-stable resolver
+
+Фикстуры и ожидаемое поведение (`orchestrator/test/hysteria-release.test.ts`):
+
+| Сценарий | Ожидание |
+| --- | --- |
+| stable `app/v2.12.2` | выбирается |
+| prerelease `app/v2.13.0` | игнорируется |
+| draft `app/v2.14.0` | игнорируется |
+| чужое семейство тегов (`core/`, `docs/`) | игнорируется |
+| тег без префикса `app/` | игнорируется |
+| `app/v2.9.10` против `app/v2.9.2` | выбирается `2.9.10` (числовое сравнение, не строковое) |
+| отсутствует `hysteria-linux-amd64` | ошибка |
+| дублирующийся `hysteria-linux-amd64` | ошибка, а не случайный выбор |
+| non-https URL артефакта | ошибка |
+| невалидный semver в теге | игнорируется |
+| пустой список релизов | понятная ошибка |
+| несовпадение SHA-256 | сборка падает |
+| сетевая ошибка / rate limit | понятная ошибка с подсказкой про `GITHUB_TOKEN` и `HYSTERIA_CHANNEL=pinned` |
+
+Отдельно проверяется, что **`latest stable` — это именно stable, а не максимальная строка или самый свежий тег**.
+
+## A2. Release rollover
+
+Ключевой acceptance-критерий модели «latest на сборке»:
+
+```text
+Сегодня: latest = 2.12.2 → пакет A закрепляет 2.12.2
+Завтра: latest = 2.12.3 → пакет B закрепляет 2.12.3
+
+Повторная установка пакета A всё равно ставит 2.12.2
+```
+
+Проверяется на двух уровнях:
+- резолвер даёт разный результат на разных снимках upstream (`orchestrator/test/release-rollover.test.ts`);
+- install-time код не импортирует резолвер, не обращается к `api.github.com` и не использует moving `latest` — это утверждение проверяется тестом и acceptance-шагом сборки.
+
+## A3. Compatibility gate
+
+1. скачанный артефакт проходит проверку SHA-256;
+2. `hysteria version` совпадает с разрешённой версией;
+3. реальный бинарник принимает канонический конфиг HY2XS для Gecko;
+4. то же для Salamander;
+5. при несовместимости падает **сборка** с сообщением `BUILD FAILED: unsupported Hysteria stable vX.Y.Z`, а не установка у пользователя.
+
+## A4. Конфигурационный контракт (unit)
+
+Таблица `orchestrator/test/env.test.ts`:
+
+| Вход | Ожидание |
+| --- | --- |
+| значение не задано | `gecko` |
+| `gecko` | принято |
+| `salamander` | принято |
+| неизвестный тип | отклонено |
+| `Gecko` (регистр) | отклонено |
+| gecko `max < min` | отклонено |
+| gecko `max > 2048` | отклонено |
+| gecko `max == 2048` | принято |
+| неположительный/нецелый `min` | отклонено |
+| пустой obfs-пароль | автогенерация, а не пустое значение в конфиге |
+| `HY2XS_CONFIG_SCHEMA_VERSION=1` | отклонено с указанием на чистую установку |
+
+Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации».
+
+Рендер конфига (`orchestrator/test/render-config.test.ts`):
+
+- Gecko рендерит **только** gecko-подблок;
+- Salamander рендерит **только** salamander-подблок;
+- в конфиге никогда нет двух подтипов obfs одновременно;
+- шаблон не содержит захардкоженного типа обфускации;
+- пароль с пробелами и спецсимволами экранируется;
+- YAML-инъекция через пароль отклоняется даже в обход env-валидации.
## B. Target install tests
@@ -56,6 +149,89 @@
21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin`
22. `hy2xs-admin` может читать `/etc/hysteria/config.yaml`, но не может писать
+## C1. Семантический smoke конфига
+
+Недостаточно `grep` по YAML: он не отличит нужное поле от такой же строки в другой секции и не заметит оставшийся рядом лишний подблок.
+
+Smoke разбирает `/etc/hysteria/config.yaml` и сверяет с production-профилем:
+
+```text
+effective Hysteria version == версия из metadata пакета
+
+obfs:
+ type == HY2XS_HYSTERIA_OBFS_TYPE
+ ровно один подблок, соответствующий type
+ password непустой
+ для gecko: minPacketSize == 512, maxPacketSize == 1200
+
+bandwidth:
+ up/down == runtime env
+ disableLossCompensation == false
+
+congestion:
+ type == bbr
+ bbrProfile == standard
+
+quic:
+ disableStatelessReset == false
+ окна и таймауты == baseline
+
+trafficStats:
+ listen == runtime env
+ secret непустой
+
+auth:
+ type == http
+ url содержит machine access_token
+
+TLS:
+ acme-режим не содержит секции tls
+ file-режим не содержит секции acme
+```
+
+## C2. End-to-end с реальным клиентом
+
+`tools/test/e2e-hysteria.sh`, отдельно для Gecko и Salamander:
+
+1. сервер принимает сгенерированный конфиг и стартует;
+2. TLS handshake;
+3. handshake с обфускацией;
+4. HTTP auth HY2XS: разрешённый пир принят;
+5. HTTP auth HY2XS: неразрешённый пир отклонён;
+6. клиент подключается **именно по сгенерированной `hysteria2://` ссылке**;
+7. TCP forwarding;
+8. UDP forwarding;
+9. `trafficStats` с валидным secret;
+10. `trafficStats` с невалидным secret отклоняется;
+11. per-peer accounting содержит аутентифицированного пира;
+12. перезапуск сервера;
+13. быстрое переподключение клиента (поведение stateless reset).
+
+Пункт 6 — тот самый, который ловит класс ошибок, неизбежный при наивном включении Gecko: сервер работает, ссылка формально валидна, а клиент по ней не подключается.
+
+## C3. Share URI (unit)
+
+`apps/service/hysteria2_api_test.go`:
+
+- Gecko URI содержит `obfs=gecko` и `obfs-password`;
+- Salamander URI содержит `obfs=salamander` и `obfs-password`;
+- конфиг без обфускации даёт ссылку без `obfs`;
+- неизвестный тип обфускации в ссылку не попадает;
+- обфускация без пароля в ссылку не попадает;
+- SNI: ACME-домен → `HY2XS_DOMAIN` → `HY2XS_PUBLIC_HOST`, IP не используется;
+- спецсимволы в credentials и obfs-пароле переживают round-trip: `+`, пробел, `#`, `@`, `/`, `?`, `&`, `=`, `%`, кириллица;
+- литеральный `+` кодируется как `%2B` и не схлопывается с пробелом (регрессия на upstream-баг 2.9.3).
+
+## C4. Экспорт конфига (unit)
+
+`apps/service/hysteria2_export_test.go`:
+
+- неизвестные upstream-секции переживают экспорт целиком, включая вложенные карты и списки;
+- операционные поля остаются читаемыми;
+- вырезаются: obfs-пароль, `trafficStats.secret`, `access_token`, `auth.userpass`, учётные данные ACME DNS, пароли outbound;
+- вырезается **неизвестное** поле с секретным именем;
+- пути к файлам (`tls.key`, `ech.keyPath`, `clientCA`) остаются видимыми.
+
## D. Negative tests
1. не Debian 13
@@ -68,6 +244,9 @@
8. install flow прерван посередине
9. попытка использовать `HY2XS_IPV6_ENABLED=true`
10. `HY2XS_PUBLIC_HOST=0.0.0.0`
+11. неизвестный `HY2XS_HYSTERIA_OBFS_TYPE`
+12. конфигурация со схемой `HY2XS_CONFIG_SCHEMA_VERSION` из линейки `0.x`
+13. upstream `latest` несовместим с шаблоном HY2XS — падает сборка, не установка
## E. Fix20 production matrix (обязательные сценарии)
@@ -119,3 +298,22 @@
13. production build verify падает, если `config/hy2xs.env` содержит placeholder-значения
14. production build verify падает при dirty git tree (кроме `ALLOW_DIRTY_BUILD=true`)
15. metadata содержит `source_git_commit`, `dirty_tree`, `build_profile=production`
+16. builder без override на сегодняшний день автоматически выбирает последнюю стабильную версию Hysteria
+17. собранный пакет содержит **точные** версию, URL и SHA-256
+18. выход новой версии Hysteria после сборки не меняет содержимое старого пакета
+19. новая установка генерирует Gecko
+20. Gecko использует `512/1200`
+21. установленная Hysteria реально принимает сгенерированный YAML
+22. сервис запускается под существующим непривилегированным пользователем `hysteria`
+23. созданный пользователь получает `hysteria2://` с `obfs=gecko` и `obfs-password`
+24. совместимый клиент Hysteria подключается напрямую по этой ссылке
+25. после перезапуска Hysteria клиент быстро восстанавливает соединение
+26. режим `HY2XS_HYSTERIA_OBFS_TYPE=salamander` полностью работоспособен
+27. admin читает Gecko-конфиг без ошибок
+28. экспорт не уничтожает современные и неизвестные upstream-поля
+29. экспорт не содержит секретов
+30. frontend отображает Gecko
+31. `namedotcom` удалён, актуальные ACME-провайдеры отражены
+32. документация нигде не утверждает, что Salamander — фиксированный инвариант
+33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
+34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
diff --git a/docs/README.md b/docs/README.md
index 4d86a74..139231d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -24,10 +24,12 @@
Здесь нет сборщика. Здесь запускается только итоговый install package / orchestrator, который:
- ставит системные зависимости
- разворачивает **встроенный HY2XS admin**
- - забирает **свежую Hysteria2 из официального upstream**
+ - забирает **закреплённую в пакете Hysteria2 из официального upstream** и сверяет её по SHA-256 и версии
- создаёт конфиги, systemd unit-файлы и `post-install.env`
- выполняет базовую настройку сервера
+Версия Hysteria2 выбирается **на builder layer**: последняя стабильная разрешается при сборке и замораживается в metadata пакета. Target layer никогда не обращается к moving `latest`.
+
## Базовые правила
1. Hysteria2 не вендорится и не собирается как часть HY2XS.
@@ -52,6 +54,9 @@
10. [10-access-layer-out-of-scope.md](10-access-layer-out-of-scope.md)
11. [11-testing-and-acceptance.md](11-testing-and-acceptance.md)
12. [12-operations-and-troubleshooting.md](12-operations-and-troubleshooting.md)
+13. [13-production-runbook.md](13-production-runbook.md)
+
+История изменений проекта — в [CHANGELOG.md](../CHANGELOG.md).
## Жёсткие рамки baseline
@@ -72,4 +77,4 @@
Правильная baseline-модель теперь такая:
-**Локальный builder собирает install package с HY2XS admin и Bun/TypeScript оркестратором; серверный install-only orchestrator ставит этот пакет на чистый Debian 13, тянет свежую Hysteria2 из upstream, разворачивает HY2XS admin, создаёт systemd + nftables + post-install env и подготавливает рабочее серверное окружение.**
+**Локальный builder разрешает последнюю стабильную Hysteria2, проверяет её на совместимость с конфигом HY2XS и собирает install package с HY2XS admin и Bun/TypeScript оркестратором; серверный install-only orchestrator ставит этот пакет на чистый Debian 13, скачивает ровно закреплённую Hysteria2, разворачивает HY2XS admin, создаёт systemd + nftables + post-install env и подготавливает рабочее серверное окружение.**
diff --git a/orchestrator/package.json b/orchestrator/package.json
index 6e09d92..4df82b2 100644
--- a/orchestrator/package.json
+++ b/orchestrator/package.json
@@ -7,6 +7,7 @@
"packageManager": "bun@1.3.13",
"scripts": {
"check": "tsc --noEmit",
+ "test": "bun test",
"build": "bun build src/cli.ts --compile --target=bun-linux-x64 --outfile dist/hy2xs-orchestrator"
},
"devDependencies": {
diff --git a/orchestrator/src/build/hysteriaRelease.ts b/orchestrator/src/build/hysteriaRelease.ts
new file mode 100644
index 0000000..8f08109
--- /dev/null
+++ b/orchestrator/src/build/hysteriaRelease.ts
@@ -0,0 +1,179 @@
+/**
+ * Build-time логика выбора upstream-релиза Hysteria.
+ *
+ * Этот модуль не входит в install-only orchestrator: target-сервер никогда
+ * не резолвит "latest" сам, он получает уже замороженные version/url/sha256.
+ */
+
+export const HYSTERIA_UPSTREAM_REPO = "HyNetworks/hysteria";
+export const HYSTERIA_LINUX_AMD64_ASSET = "hysteria-linux-amd64";
+
+/** Upstream публикует несколько семейств тегов; серверный бинарник живёт в app/vX.Y.Z. */
+export const HYSTERIA_APP_TAG_PATTERN = /^app\/v(\d+)\.(\d+)\.(\d+)$/;
+export const HYSTERIA_VERSION_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/;
+
+export type GithubAsset = {
+ name: string;
+ browser_download_url: string;
+};
+
+export type GithubRelease = {
+ tag_name: string;
+ draft?: boolean;
+ prerelease?: boolean;
+ published_at?: string | null;
+ assets?: GithubAsset[];
+};
+
+export type ResolvedHysteriaRelease = {
+ /** Нормализованная версия вида v2.12.2. */
+ version: string;
+ /** Полный upstream-тег вида app/v2.12.2. */
+ tag: string;
+ /** Immutable release asset URL ровно в том виде, как его отдал GitHub API. */
+ artifactUrl: string;
+ publishedAt: string;
+};
+
+type SemverParts = [number, number, number];
+
+export function parseVersion(value: string): SemverParts | null {
+ const match = HYSTERIA_VERSION_PATTERN.exec(value.trim());
+ if (!match) {
+ return null;
+ }
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
+}
+
+export function parseAppTag(tag: string): { version: string; parts: SemverParts } | null {
+ const match = HYSTERIA_APP_TAG_PATTERN.exec(tag.trim());
+ if (!match) {
+ return null;
+ }
+ const parts: SemverParts = [Number(match[1]), Number(match[2]), Number(match[3])];
+ return { version: `v${parts[0]}.${parts[1]}.${parts[2]}`, parts };
+}
+
+/** Числовое сравнение, а не лексикографическое: v2.9.10 новее v2.9.2. */
+export function compareVersionParts(a: SemverParts, b: SemverParts): number {
+ for (let i = 0; i < 3; i += 1) {
+ if (a[i] !== b[i]) {
+ return a[i] < b[i] ? -1 : 1;
+ }
+ }
+ return 0;
+}
+
+function isStable(release: GithubRelease): boolean {
+ return release.draft !== true && release.prerelease !== true;
+}
+
+export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset {
+ const matches = (release.assets ?? []).filter((asset) => asset.name === HYSTERIA_LINUX_AMD64_ASSET);
+
+ if (matches.length === 0) {
+ throw new Error(
+ `upstream release ${release.tag_name} has no ${HYSTERIA_LINUX_AMD64_ASSET} asset`
+ );
+ }
+ if (matches.length > 1) {
+ throw new Error(
+ `upstream release ${release.tag_name} has ${matches.length} ambiguous ${HYSTERIA_LINUX_AMD64_ASSET} assets`
+ );
+ }
+
+ const url = matches[0].browser_download_url?.trim();
+ if (!url) {
+ throw new Error(`upstream release ${release.tag_name} has an empty asset download url`);
+ }
+ if (!url.startsWith("https://")) {
+ throw new Error(`upstream release ${release.tag_name} asset url is not https: ${url}`);
+ }
+
+ return { name: matches[0].name, browser_download_url: url };
+}
+
+function toResolved(release: GithubRelease, version: string): ResolvedHysteriaRelease {
+ return {
+ version,
+ tag: release.tag_name.trim(),
+ artifactUrl: selectLinuxAmd64Asset(release).browser_download_url,
+ publishedAt: (release.published_at ?? "").trim()
+ };
+}
+
+/**
+ * Выбирает последний стабильный app-релиз: без draft и prerelease, тег строго
+ * app/vX.Y.Z, наибольшая версия по числовому сравнению.
+ */
+export function selectLatestStableRelease(releases: GithubRelease[]): ResolvedHysteriaRelease {
+ let best: { release: GithubRelease; version: string; parts: SemverParts } | null = null;
+
+ for (const release of releases) {
+ if (!isStable(release)) {
+ continue;
+ }
+ const parsed = parseAppTag(release.tag_name ?? "");
+ if (!parsed) {
+ continue;
+ }
+ if (!best || compareVersionParts(parsed.parts, best.parts) > 0) {
+ best = { release, version: parsed.version, parts: parsed.parts };
+ }
+ }
+
+ if (!best) {
+ throw new Error(
+ `no stable ${HYSTERIA_UPSTREAM_REPO} release matching app/vX.Y.Z found in upstream release list`
+ );
+ }
+
+ return toResolved(best.release, best.version);
+}
+
+/** Выбирает конкретную версию по HYSTERIA_VERSION_OVERRIDE. */
+export function selectReleaseByVersion(
+ releases: GithubRelease[],
+ version: string
+): ResolvedHysteriaRelease {
+ const requested = parseVersion(version);
+ if (!requested) {
+ throw new Error(`invalid Hysteria version override: ${version} (expected vX.Y.Z)`);
+ }
+
+ const normalized = `v${requested[0]}.${requested[1]}.${requested[2]}`;
+
+ for (const release of releases) {
+ const parsed = parseAppTag(release.tag_name ?? "");
+ if (parsed?.version === normalized) {
+ if (release.draft === true) {
+ throw new Error(`Hysteria version override ${normalized} points to a draft release`);
+ }
+ return toResolved(release, parsed.version);
+ }
+ }
+
+ throw new Error(`Hysteria version override ${normalized} was not found in ${HYSTERIA_UPSTREAM_REPO}`);
+}
+
+export function renderLockEnv(
+ resolved: ResolvedHysteriaRelease,
+ sha256: string,
+ resolution: string,
+ resolvedAt: string
+): string {
+ if (!/^[a-f0-9]{64}$/.test(sha256)) {
+ throw new Error(`invalid Hysteria artifact sha256: ${sha256}`);
+ }
+
+ return [
+ "# Файл генерируется tools/build/lib/hysteria.sh.",
+ "# HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.",
+ `HYSTERIA_VERSION=${resolved.version}`,
+ `HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`,
+ `HYSTERIA_ARTIFACT_SHA256=${sha256}`,
+ `HYSTERIA_RESOLUTION=${resolution}`,
+ `HYSTERIA_RESOLVED_AT=${resolvedAt}`,
+ ""
+ ].join("\n");
+}
diff --git a/orchestrator/src/commands/doctor.ts b/orchestrator/src/commands/doctor.ts
index 615b8a8..88247bf 100644
--- a/orchestrator/src/commands/doctor.ts
+++ b/orchestrator/src/commands/doctor.ts
@@ -69,7 +69,8 @@ export async function doctor(options: ReconfigureOptions): Promise {
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
- hysteriaVersion: await readInstalledHysteriaVersion()
+ hysteriaVersion: await readInstalledHysteriaVersion(),
+ hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
};
step("doctor preflight");
diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts
index e419061..09ae4ed 100644
--- a/orchestrator/src/commands/install.ts
+++ b/orchestrator/src/commands/install.ts
@@ -159,6 +159,7 @@ export async function install(options: InstallOptions): Promise {
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
hysteriaVersion: "unknown",
+ hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown"),
hysteriaTargetVersion: await readPackageValue(options.packageDir, "hysteria.version", ""),
hysteriaArtifactUrl: await readPackageValue(options.packageDir, "hysteria.url", ""),
hysteriaArtifactSha256: await readPackageValue(options.packageDir, "hysteria.sha256", "")
diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts
index 57bda6b..9f6221c 100644
--- a/orchestrator/src/commands/reconfigure.ts
+++ b/orchestrator/src/commands/reconfigure.ts
@@ -176,7 +176,8 @@ export async function reconfigure(options: ReconfigureOptions): Promise {
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
- hysteriaVersion: await readInstalledHysteriaVersion()
+ hysteriaVersion: await readInstalledHysteriaVersion(),
+ hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
};
step("preflight");
diff --git a/orchestrator/src/config/env.ts b/orchestrator/src/config/env.ts
index 34e45ab..a259fa6 100644
--- a/orchestrator/src/config/env.ts
+++ b/orchestrator/src/config/env.ts
@@ -1,5 +1,12 @@
import { randomBytes } from "node:crypto";
import type { FirewallMode, RuntimeConfig, TlsMode } from "../types/context";
+import {
+ GECKO_DEFAULT_MAX_PACKET_SIZE,
+ GECKO_DEFAULT_MIN_PACKET_SIZE,
+ HY2XS_CONFIG_SCHEMA_VERSION,
+ normalizeHysteriaObfsType,
+ validateGeckoPacketSizes
+} from "./profile";
type EnvMap = Record;
@@ -143,12 +150,22 @@ function normalizeFixedHysteriaAuthMode(value: string | undefined): "http" {
return "http";
}
-function normalizeFixedHysteriaObfsType(value: string | undefined): "salamander" {
- const obfsType = value || "salamander";
- if (obfsType !== "salamander") {
- throw new Error("HY2XS_HYSTERIA_OBFS_TYPE is fixed in HY2XS production profile: salamander");
+function normalizeConfigSchemaVersion(value: string | undefined): number {
+ const raw = (value ?? "").trim();
+ if (!raw) {
+ return HY2XS_CONFIG_SCHEMA_VERSION;
}
- return "salamander";
+ const parsed = Number(raw);
+ if (!Number.isInteger(parsed) || parsed < 1) {
+ throw new Error(`invalid HY2XS_CONFIG_SCHEMA_VERSION: ${value}`);
+ }
+ if (parsed !== HY2XS_CONFIG_SCHEMA_VERSION) {
+ throw new Error(
+ `unsupported HY2XS_CONFIG_SCHEMA_VERSION: ${parsed}. This package understands schema ${HY2XS_CONFIG_SCHEMA_VERSION}; ` +
+ "HY2XS v1 requires a clean installation and does not migrate v0 configuration in place."
+ );
+ }
+ return parsed;
}
function normalizeYamlSafeSecret(name: string, value: string): string {
@@ -191,9 +208,10 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
const dnsAaaaPolicy = normalizeDnsAaaaPolicy(env.HY2XS_DNS_AAAA_POLICY);
const firewallMode = normalizeFirewallMode(env);
const hysteriaAuthMode = normalizeFixedHysteriaAuthMode(env.HY2XS_HYSTERIA_AUTH_MODE);
- const hysteriaObfsType = normalizeFixedHysteriaObfsType(env.HY2XS_HYSTERIA_OBFS_TYPE);
+ const hysteriaObfsType = normalizeHysteriaObfsType(env.HY2XS_HYSTERIA_OBFS_TYPE);
const config: RuntimeConfig = {
+ configSchemaVersion: normalizeConfigSchemaVersion(env.HY2XS_CONFIG_SCHEMA_VERSION),
domain: env.HY2XS_DOMAIN || "",
dnsAaaaPolicy,
publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""),
@@ -232,6 +250,10 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
"HY2XS_HYSTERIA_OBFS_PASSWORD",
valueOrGenerate(env.HY2XS_HYSTERIA_OBFS_PASSWORD)
),
+ // Gecko packet sizes не выносятся в env: share URI не умеет их передавать,
+ // поэтому любое нестандартное значение сделало бы hysteria2:// неполным.
+ hysteriaGeckoMinPacketSize: GECKO_DEFAULT_MIN_PACKET_SIZE,
+ hysteriaGeckoMaxPacketSize: GECKO_DEFAULT_MAX_PACKET_SIZE,
hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
hysteriaIgnoreClientBandwidth: parseBool(
@@ -251,6 +273,9 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
}
export function validateRuntimeConfig(config: RuntimeConfig): void {
+ if (config.hysteriaObfsType === "gecko") {
+ validateGeckoPacketSizes(config.hysteriaGeckoMinPacketSize, config.hysteriaGeckoMaxPacketSize);
+ }
if (config.ipv6Enabled) {
throw new Error("HY2XS is IPv4-only: HY2XS_IPV6_ENABLED must be false");
}
@@ -297,7 +322,8 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
export function renderRuntimeEnv(config: RuntimeConfig): string {
const lines = [
"# HY2XS runtime config (editable)",
- "HY2XS_IPV6_ENABLED=false",
+ `HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`,
+ `HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`,
`HY2XS_DOMAIN=${config.domain}`,
`HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`,
`HY2XS_PUBLIC_HOST=${config.publicHost}`,
@@ -320,11 +346,11 @@ export function renderRuntimeEnv(config: RuntimeConfig): string {
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`,
- "HY2XS_HYSTERIA_AUTH_MODE=http",
+ `HY2XS_HYSTERIA_AUTH_MODE=${config.hysteriaAuthMode}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`,
- "HY2XS_HYSTERIA_OBFS_TYPE=salamander",
+ `HY2XS_HYSTERIA_OBFS_TYPE=${config.hysteriaObfsType}`,
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
diff --git a/orchestrator/src/config/profile.ts b/orchestrator/src/config/profile.ts
new file mode 100644
index 0000000..d5f8a45
--- /dev/null
+++ b/orchestrator/src/config/profile.ts
@@ -0,0 +1,138 @@
+import type { HysteriaObfsType, RuntimeConfig } from "../types/context";
+
+/**
+ * HY2XS production profile: единственное место, где определены значения
+ * серверной политики. Всё остальное (шаблоны, post-install.env, smoke,
+ * build-time compatibility gate) только передаёт эти значения дальше.
+ */
+
+export const HY2XS_CONFIG_SCHEMA_VERSION = 2;
+
+export const HYSTERIA_OBFS_TYPES: readonly HysteriaObfsType[] = ["gecko", "salamander"];
+
+/** Тип обфускации для новой установки. Salamander остаётся compatibility fallback. */
+export const DEFAULT_HYSTERIA_OBFS_TYPE: HysteriaObfsType = "gecko";
+
+/** Upstream defaults Gecko. HY2XS фиксирует их явно как tested production profile. */
+export const GECKO_DEFAULT_MIN_PACKET_SIZE = 512;
+export const GECKO_DEFAULT_MAX_PACKET_SIZE = 1200;
+
+/** Upstream ограничение: maxPacketSize >= minPacketSize и <= 2048. */
+export const GECKO_MAX_PACKET_SIZE_LIMIT = 2048;
+
+/**
+ * Fallback congestion controller. Используется, когда Brutal bandwidth
+ * не согласован сторонами; сам Brutal включается через bandwidth up/down.
+ */
+export const CONGESTION_TYPE = "bbr";
+export const BBR_PROFILE = "standard";
+
+/**
+ * Loss compensation оставлен включённым (upstream default), поэтому
+ * в конфиге явно фиксируется disableLossCompensation: false.
+ */
+export const DISABLE_LOSS_COMPENSATION = false;
+
+/**
+ * QUIC stateless reset нужен HY2XS: клиент со stale-соединением после
+ * перезапуска сервера или сна устройства переподключается сразу.
+ */
+export const DISABLE_STATELESS_RESET = false;
+
+export const QUIC_BASELINE = {
+ initStreamReceiveWindow: 8388608,
+ maxStreamReceiveWindow: 8388608,
+ initConnReceiveWindow: 20971520,
+ maxConnReceiveWindow: 20971520,
+ maxIdleTimeout: "30s",
+ maxIncomingStreams: 1024,
+ disablePathMTUDiscovery: false
+} as const;
+
+export function isHysteriaObfsType(value: string): value is HysteriaObfsType {
+ return (HYSTERIA_OBFS_TYPES as readonly string[]).includes(value);
+}
+
+export function normalizeHysteriaObfsType(value: string | undefined): HysteriaObfsType {
+ const obfsType = (value ?? "").trim() || DEFAULT_HYSTERIA_OBFS_TYPE;
+ if (!isHysteriaObfsType(obfsType)) {
+ throw new Error(
+ `invalid HY2XS_HYSTERIA_OBFS_TYPE: ${value} (supported: ${HYSTERIA_OBFS_TYPES.join(", ")})`
+ );
+ }
+ return obfsType;
+}
+
+export function validateGeckoPacketSizes(minPacketSize: number, maxPacketSize: number): void {
+ if (!Number.isInteger(minPacketSize) || minPacketSize <= 0) {
+ throw new Error(`invalid Gecko minPacketSize: ${minPacketSize} (must be a positive integer)`);
+ }
+ if (!Number.isInteger(maxPacketSize) || maxPacketSize <= 0) {
+ throw new Error(`invalid Gecko maxPacketSize: ${maxPacketSize} (must be a positive integer)`);
+ }
+ if (maxPacketSize < minPacketSize) {
+ throw new Error(
+ `invalid Gecko packet sizes: maxPacketSize ${maxPacketSize} must be >= minPacketSize ${minPacketSize}`
+ );
+ }
+ if (maxPacketSize > GECKO_MAX_PACKET_SIZE_LIMIT) {
+ throw new Error(
+ `invalid Gecko maxPacketSize: ${maxPacketSize} (upstream limit is ${GECKO_MAX_PACKET_SIZE_LIMIT})`
+ );
+ }
+}
+
+function assertYamlSafeQuoted(name: string, value: string): string {
+ if (!value) {
+ throw new Error(`missing required ${name}`);
+ }
+ if (/["\n\r]/.test(value)) {
+ throw new Error(`${name} contains forbidden characters for HY2XS YAML profile`);
+ }
+ return value;
+}
+
+/**
+ * Рендерит целиком проверенный obfs-блок. Type selector никогда не собирается
+ * внутри статического YAML, поэтому комбинация вида `type: gecko` + `salamander:`
+ * структурно невозможна.
+ */
+export function renderObfsBlock(config: RuntimeConfig): string {
+ const password = assertYamlSafeQuoted("HY2XS_HYSTERIA_OBFS_PASSWORD", config.hysteriaObfsPassword);
+
+ if (config.hysteriaObfsType === "gecko") {
+ validateGeckoPacketSizes(config.hysteriaGeckoMinPacketSize, config.hysteriaGeckoMaxPacketSize);
+ return [
+ "obfs:",
+ " type: gecko",
+ " gecko:",
+ ` password: "${password}"`,
+ ` minPacketSize: ${config.hysteriaGeckoMinPacketSize}`,
+ ` maxPacketSize: ${config.hysteriaGeckoMaxPacketSize}`
+ ].join("\n");
+ }
+
+ if (config.hysteriaObfsType === "salamander") {
+ return ["obfs:", " type: salamander", " salamander:", ` password: "${password}"`].join("\n");
+ }
+
+ throw new Error(`unsupported obfs type: ${config.hysteriaObfsType satisfies never}`);
+}
+
+export function renderCongestionBlock(): string {
+ return ["congestion:", ` type: ${CONGESTION_TYPE}`, ` bbrProfile: ${BBR_PROFILE}`].join("\n");
+}
+
+export function renderQuicBlock(): string {
+ return [
+ "quic:",
+ ` initStreamReceiveWindow: ${QUIC_BASELINE.initStreamReceiveWindow}`,
+ ` maxStreamReceiveWindow: ${QUIC_BASELINE.maxStreamReceiveWindow}`,
+ ` initConnReceiveWindow: ${QUIC_BASELINE.initConnReceiveWindow}`,
+ ` maxConnReceiveWindow: ${QUIC_BASELINE.maxConnReceiveWindow}`,
+ ` maxIdleTimeout: ${QUIC_BASELINE.maxIdleTimeout}`,
+ ` maxIncomingStreams: ${QUIC_BASELINE.maxIncomingStreams}`,
+ ` disablePathMTUDiscovery: ${QUIC_BASELINE.disablePathMTUDiscovery}`,
+ ` disableStatelessReset: ${DISABLE_STATELESS_RESET}`
+ ].join("\n");
+}
diff --git a/orchestrator/src/steps/config.ts b/orchestrator/src/steps/config.ts
index a652dda..fdec678 100644
--- a/orchestrator/src/steps/config.ts
+++ b/orchestrator/src/steps/config.ts
@@ -1,8 +1,23 @@
import type { RuntimeContext } from "../types/context";
import { readText, renderTemplate, writeText } from "../lib/fs";
import { runVisible } from "../lib/process";
+import {
+ DISABLE_LOSS_COMPENSATION,
+ renderCongestionBlock,
+ renderObfsBlock,
+ renderQuicBlock
+} from "../config/profile";
-export async function generateConfig(context: RuntimeContext): Promise {
+export function hysteriaConfigTemplatePath(packageDir: string): string {
+ return `${packageDir}/templates/hysteria/config.yaml.tpl`;
+}
+
+/**
+ * Чистый рендер серверного конфига Hysteria2. Используется и на target-сервере,
+ * и build-time compatibility gate, чтобы проверялся ровно тот YAML, который
+ * получит production.
+ */
+export function renderHysteriaConfig(context: RuntimeContext, template: string): string {
const tlsAcmeBlock = context.config.tlsMode === "acme"
? `acme:\n domains:\n - ${context.config.domain}\n email: ${context.config.acmeEmail}\n ca: letsencrypt\n dir: /var/lib/hysteria/acme\n listenHost: 0.0.0.0\n type: ${context.config.acmeType}`
: "";
@@ -10,22 +25,29 @@ export async function generateConfig(context: RuntimeContext): Promise {
? `tls:\n cert: ${context.config.tlsCertPath}\n key: ${context.config.tlsKeyPath}`
: "";
- const template = await readText(`${context.options.packageDir}/templates/hysteria/config.yaml.tpl`);
- const rendered = renderTemplate(template, {
+ return renderTemplate(template, {
HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
HYSTERIA_PORT: context.config.hysteriaPort,
- HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword,
HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost,
HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort,
HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret,
UI_PORT: context.config.uiPort,
BANDWIDTH_UP: context.config.hysteriaBandwidthUp,
BANDWIDTH_DOWN: context.config.hysteriaBandwidthDown,
+ DISABLE_LOSS_COMPENSATION: DISABLE_LOSS_COMPENSATION ? "true" : "false",
IGNORE_CLIENT_BANDWIDTH: context.config.hysteriaIgnoreClientBandwidth ? "true" : "false",
+ OBFS_BLOCK: renderObfsBlock(context.config),
+ CONGESTION_BLOCK: renderCongestionBlock(),
+ QUIC_BLOCK: renderQuicBlock(),
TLS_ACME_BLOCK: tlsAcmeBlock,
TLS_FILE_BLOCK: tlsFileBlock,
AUTH_INSECURE: context.config.tlsMode === "self_signed_dev" ? "true" : "false"
});
+}
+
+export async function generateConfig(context: RuntimeContext): Promise {
+ const template = await readText(hysteriaConfigTemplatePath(context.options.packageDir));
+ const rendered = renderHysteriaConfig(context, template);
const configPath = context.config.hysteriaConfigPath;
const tmpPath = `${configPath}.tmp`;
diff --git a/orchestrator/src/steps/configAssertions.ts b/orchestrator/src/steps/configAssertions.ts
new file mode 100644
index 0000000..4ff521d
--- /dev/null
+++ b/orchestrator/src/steps/configAssertions.ts
@@ -0,0 +1,151 @@
+import type { RuntimeConfig } from "../types/context";
+import {
+ BBR_PROFILE,
+ CONGESTION_TYPE,
+ DISABLE_LOSS_COMPENSATION,
+ DISABLE_STATELESS_RESET,
+ HYSTERIA_OBFS_TYPES,
+ QUIC_BASELINE
+} from "../config/profile";
+
+/**
+ * Семантическая проверка сгенерированного серверного конфига.
+ *
+ * Grep по YAML здесь недостаточен: он не отличит `disableStatelessReset: false`
+ * внутри quic от такой же строки где-то ещё и не заметит, что рядом с
+ * `type: gecko` остался блок salamander.
+ */
+
+type YamlRecord = Record;
+
+function asRecord(value: unknown, path: string): YamlRecord {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`hysteria config: ${path} must be a mapping, got ${JSON.stringify(value)}`);
+ }
+ return value as YamlRecord;
+}
+
+function requireSection(root: YamlRecord, key: string): YamlRecord {
+ if (!(key in root)) {
+ throw new Error(`hysteria config: missing required section ${key}`);
+ }
+ return asRecord(root[key], key);
+}
+
+function expectValue(section: YamlRecord, path: string, key: string, expected: unknown): void {
+ const actual = section[key];
+ if (actual !== expected) {
+ throw new Error(
+ `hysteria config: ${path}.${key} must be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
+ );
+ }
+}
+
+function expectNonEmptyString(section: YamlRecord, path: string, key: string): void {
+ const actual = section[key];
+ if (typeof actual !== "string" || actual.trim() === "") {
+ throw new Error(`hysteria config: ${path}.${key} must be a non-empty string, got ${JSON.stringify(actual)}`);
+ }
+}
+
+export function parseHysteriaConfig(raw: string): YamlRecord {
+ const parsed = Bun.YAML.parse(raw);
+ return asRecord(parsed, "");
+}
+
+/**
+ * Проверяет, что установленный конфиг действительно соответствует
+ * HY2XS production profile, а не просто содержит нужные подстроки.
+ */
+export function assertHysteriaConfigMatchesProfile(raw: string, config: RuntimeConfig): void {
+ const root = parseHysteriaConfig(raw);
+
+ const listen = root.listen;
+ if (listen !== `${config.hysteriaBindHost}:${config.hysteriaPort}`) {
+ throw new Error(
+ `hysteria config: listen must be ${config.hysteriaBindHost}:${config.hysteriaPort}, got ${JSON.stringify(listen)}`
+ );
+ }
+
+ assertObfsSection(root, config);
+
+ const bandwidth = requireSection(root, "bandwidth");
+ expectValue(bandwidth, "bandwidth", "up", config.hysteriaBandwidthUp);
+ expectValue(bandwidth, "bandwidth", "down", config.hysteriaBandwidthDown);
+ expectValue(bandwidth, "bandwidth", "disableLossCompensation", DISABLE_LOSS_COMPENSATION);
+
+ expectValue(root, "", "ignoreClientBandwidth", config.hysteriaIgnoreClientBandwidth);
+
+ const congestion = requireSection(root, "congestion");
+ expectValue(congestion, "congestion", "type", CONGESTION_TYPE);
+ expectValue(congestion, "congestion", "bbrProfile", BBR_PROFILE);
+
+ const quic = requireSection(root, "quic");
+ expectValue(quic, "quic", "disableStatelessReset", DISABLE_STATELESS_RESET);
+ expectValue(quic, "quic", "initStreamReceiveWindow", QUIC_BASELINE.initStreamReceiveWindow);
+ expectValue(quic, "quic", "maxStreamReceiveWindow", QUIC_BASELINE.maxStreamReceiveWindow);
+ expectValue(quic, "quic", "initConnReceiveWindow", QUIC_BASELINE.initConnReceiveWindow);
+ expectValue(quic, "quic", "maxConnReceiveWindow", QUIC_BASELINE.maxConnReceiveWindow);
+ expectValue(quic, "quic", "maxIncomingStreams", QUIC_BASELINE.maxIncomingStreams);
+ expectValue(quic, "quic", "disablePathMTUDiscovery", QUIC_BASELINE.disablePathMTUDiscovery);
+
+ const trafficStats = requireSection(root, "trafficStats");
+ expectValue(
+ trafficStats,
+ "trafficStats",
+ "listen",
+ `${config.hysteriaTrafficStatsHost}:${config.hysteriaTrafficStatsPort}`
+ );
+ expectNonEmptyString(trafficStats, "trafficStats", "secret");
+
+ const auth = requireSection(root, "auth");
+ expectValue(auth, "auth", "type", "http");
+ const authHttp = asRecord(auth.http, "auth.http");
+ expectNonEmptyString(authHttp, "auth.http", "url");
+ if (!String(authHttp.url).includes("access_token=")) {
+ throw new Error("hysteria config: auth.http.url must carry the HY2XS machine access token");
+ }
+
+ assertTlsSection(root, config);
+}
+
+function assertObfsSection(root: YamlRecord, config: RuntimeConfig): void {
+ const obfs = requireSection(root, "obfs");
+ expectValue(obfs, "obfs", "type", config.hysteriaObfsType);
+
+ const present = HYSTERIA_OBFS_TYPES.filter((subtype) => subtype in obfs);
+ if (present.length !== 1 || present[0] !== config.hysteriaObfsType) {
+ throw new Error(
+ `hysteria config: obfs must contain exactly the ${config.hysteriaObfsType} subsection, found [${present.join(", ")}]`
+ );
+ }
+
+ const subtype = asRecord(obfs[config.hysteriaObfsType], `obfs.${config.hysteriaObfsType}`);
+ expectNonEmptyString(subtype, `obfs.${config.hysteriaObfsType}`, "password");
+
+ if (config.hysteriaObfsType === "gecko") {
+ expectValue(subtype, "obfs.gecko", "minPacketSize", config.hysteriaGeckoMinPacketSize);
+ expectValue(subtype, "obfs.gecko", "maxPacketSize", config.hysteriaGeckoMaxPacketSize);
+ }
+}
+
+function assertTlsSection(root: YamlRecord, config: RuntimeConfig): void {
+ if (config.tlsMode === "acme") {
+ if ("tls" in root) {
+ throw new Error("hysteria config: acme mode must not emit a tls section");
+ }
+ const acme = requireSection(root, "acme");
+ expectValue(acme, "acme", "type", config.acmeType);
+ if (!Array.isArray(acme.domains) || acme.domains[0] !== config.domain) {
+ throw new Error(`hysteria config: acme.domains must start with ${config.domain}`);
+ }
+ return;
+ }
+
+ if ("acme" in root) {
+ throw new Error(`hysteria config: ${config.tlsMode} mode must not emit an acme section`);
+ }
+ const tls = requireSection(root, "tls");
+ expectValue(tls, "tls", "cert", config.tlsCertPath);
+ expectValue(tls, "tls", "key", config.tlsKeyPath);
+}
diff --git a/orchestrator/src/steps/env.ts b/orchestrator/src/steps/env.ts
index d01be54..69cc8b1 100644
--- a/orchestrator/src/steps/env.ts
+++ b/orchestrator/src/steps/env.ts
@@ -1,11 +1,18 @@
import type { RuntimeContext } from "../types/context";
import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs";
import { runVisible } from "../lib/process";
+import {
+ BBR_PROFILE,
+ CONGESTION_TYPE,
+ DISABLE_LOSS_COMPENSATION,
+ DISABLE_STATELESS_RESET
+} from "../config/profile";
export async function writePostInstallEnv(context: RuntimeContext): Promise {
const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/env/post-install.env.tpl`), {
PACKAGE_VERSION: context.packageVersion,
PACKAGE_BUILD_ID: context.packageBuildId,
+ CONFIG_SCHEMA_VERSION: context.config.configSchemaVersion,
LAST_APPLY_DATE: context.installDate,
DOMAIN: context.config.domain,
PUBLIC_HOST: context.config.publicHost,
@@ -14,16 +21,24 @@ export async function writePostInstallEnv(context: RuntimeContext): Promise {
await runVisible`nft -c -f /etc/nftables.conf`;
- if (context.config.tlsMode === "acme") {
- await runVisible`grep -q '^acme:' /etc/hysteria/config.yaml`;
- await runVisible`! grep -q '^tls:' /etc/hysteria/config.yaml`;
+ // Семантическая проверка установленного конфига: разбираем YAML и сверяем
+ // с production-профилем, а не ищем подстроки.
+ info("verifying effective Hysteria config against HY2XS production profile");
+ assertHysteriaConfigMatchesProfile(await readText("/etc/hysteria/config.yaml"), context.config);
+
+ await assertEffectiveHysteriaVersion(context);
+}
+
+/**
+ * Установленный бинарник обязан совпадать с версией, замороженной в metadata
+ * пакета. На reconfigure metadata может относиться к другому пакету, поэтому
+ * расхождение там — предупреждение, а не отказ.
+ */
+async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise {
+ const packagedVersion = context.hysteriaVersion.trim();
+ if (!packagedVersion || packagedVersion === "unknown") {
+ return;
}
- if (context.config.tlsMode === "file") {
- await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`;
- await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`;
- await runVisible`grep -q 'insecure: false' /etc/hysteria/config.yaml`;
- }
- if (context.config.tlsMode === "self_signed_dev") {
- await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`;
- await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`;
- await runVisible`grep -q 'insecure: true' /etc/hysteria/config.yaml`;
+
+ const raw = await runSecret`/usr/local/bin/hysteria version`;
+ const match = raw.match(/v\d+\.\d+\.\d+/);
+ const effective = match ? match[0] : raw.trim();
+
+ if (effective !== packagedVersion) {
+ const message = `effective Hysteria version ${effective} does not match package metadata ${packagedVersion}`;
+ if (context.mode === "install") {
+ throw new Error(message);
+ }
+ info(`warning: ${message}`);
}
}
diff --git a/orchestrator/src/types/context.ts b/orchestrator/src/types/context.ts
index b8df827..e6c3d4e 100644
--- a/orchestrator/src/types/context.ts
+++ b/orchestrator/src/types/context.ts
@@ -24,7 +24,10 @@ export type TlsMode = "acme" | "file" | "self_signed_dev";
export type DnsAaaaPolicy = "strict" | "warn" | "off";
+export type HysteriaObfsType = "gecko" | "salamander";
+
export type RuntimeConfig = {
+ configSchemaVersion: number;
domain: string;
dnsAaaaPolicy: DnsAaaaPolicy;
publicHost: string;
@@ -52,8 +55,10 @@ export type RuntimeConfig = {
hysteriaTrafficStatsHost: string;
hysteriaTrafficStatsPort: number;
hysteriaTrafficStatsSecret: string;
- hysteriaObfsType: "salamander";
+ hysteriaObfsType: HysteriaObfsType;
hysteriaObfsPassword: string;
+ hysteriaGeckoMinPacketSize: number;
+ hysteriaGeckoMaxPacketSize: number;
hysteriaBandwidthUp: string;
hysteriaBandwidthDown: string;
hysteriaIgnoreClientBandwidth: boolean;
@@ -72,6 +77,8 @@ export type RuntimeContext = {
packageBuildId: string;
installDate: string;
hysteriaVersion: string;
+ /** Как версия Hysteria была выбрана на этапе сборки пакета: latest-stable | pinned | override. */
+ hysteriaResolution: string;
};
export type InstallContext = RuntimeContext & {
diff --git a/orchestrator/test/config-assertions.test.ts b/orchestrator/test/config-assertions.test.ts
new file mode 100644
index 0000000..2a6b41e
--- /dev/null
+++ b/orchestrator/test/config-assertions.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { renderHysteriaConfig } from "../src/steps/config";
+import { assertHysteriaConfigMatchesProfile } from "../src/steps/configAssertions";
+import { baselineConfig, testContext } from "./fixtures";
+
+const TEMPLATE = readFileSync(
+ join(import.meta.dir, "..", "..", "package", "templates", "hysteria", "config.yaml.tpl"),
+ "utf8"
+);
+
+function renderFor(overrides: Record = {}) {
+ const config = baselineConfig(overrides);
+ return { config, yaml: renderHysteriaConfig(testContext(config), TEMPLATE) };
+}
+
+describe("сгенерированный конфиг проходит собственную семантическую проверку", () => {
+ for (const obfsType of ["gecko", "salamander"]) {
+ for (const tlsMode of ["acme", "file"]) {
+ test(`obfs=${obfsType}, tls=${tlsMode}`, () => {
+ const { config, yaml } = renderFor({
+ HY2XS_HYSTERIA_OBFS_TYPE: obfsType,
+ HY2XS_TLS_MODE: tlsMode
+ });
+ expect(() => assertHysteriaConfigMatchesProfile(yaml, config)).not.toThrow();
+ });
+ }
+ }
+});
+
+describe("подмены в конфиге обнаруживаются", () => {
+ test("тип obfs не совпадает с профилем", () => {
+ const { config, yaml } = renderFor({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
+ const tampered = yaml.replace("type: gecko", "type: salamander");
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/obfs.*type/);
+ });
+
+ test("в obfs остался второй подтип", () => {
+ const { config, yaml } = renderFor({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
+ const tampered = yaml.replace(
+ "obfs:\n type: gecko",
+ "obfs:\n salamander:\n password: \"leftover\"\n type: gecko"
+ );
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/exactly the gecko subsection/);
+ });
+
+ test("изменённый gecko packet size", () => {
+ const { config, yaml } = renderFor();
+ const tampered = yaml.replace("maxPacketSize: 1200", "maxPacketSize: 1400");
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/maxPacketSize/);
+ });
+
+ test("stateless reset выключен", () => {
+ const { config, yaml } = renderFor();
+ const tampered = yaml.replace("disableStatelessReset: false", "disableStatelessReset: true");
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/disableStatelessReset/);
+ });
+
+ test("loss compensation выключена", () => {
+ const { config, yaml } = renderFor();
+ const tampered = yaml.replace("disableLossCompensation: false", "disableLossCompensation: true");
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/disableLossCompensation/);
+ });
+
+ test("подменён congestion controller", () => {
+ const { config, yaml } = renderFor();
+ const tampered = yaml.replace("type: bbr", "type: reno");
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/congestion\.type/);
+ });
+
+ test("подменён bbr profile", () => {
+ const { config, yaml } = renderFor();
+ const tampered = yaml.replace("bbrProfile: standard", "bbrProfile: aggressive");
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/bbrProfile/);
+ });
+
+ test("исчезла секция congestion", () => {
+ const { config, yaml } = renderFor();
+ const tampered = yaml.replace(/congestion:\n {2}type: bbr\n {2}bbrProfile: standard\n/, "");
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/missing required section congestion/);
+ });
+
+ test("auth url потерял machine token", () => {
+ const { config, yaml } = renderFor();
+ const tampered = yaml.replace(/\?access_token=[^\s]*/, "");
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/machine access token/);
+ });
+
+ test("пустой obfs-пароль", () => {
+ const { config, yaml } = renderFor();
+ const tampered = yaml.replace(/password: "[^"]*"/, 'password: ""');
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/non-empty string/);
+ });
+
+ test("acme-профиль с посторонней tls-секцией", () => {
+ const { config, yaml } = renderFor({ HY2XS_TLS_MODE: "acme" });
+ const tampered = `${yaml}\ntls:\n cert: /tmp/x.crt\n key: /tmp/x.key\n`;
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/must not emit a tls section/);
+ });
+
+ test("file-профиль с посторонней acme-секцией", () => {
+ const { config, yaml } = renderFor({ HY2XS_TLS_MODE: "file" });
+ const tampered = `${yaml}\nacme:\n domains:\n - x.example.com\n`;
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/must not emit an acme section/);
+ });
+
+ test("подменён listen", () => {
+ const { config, yaml } = renderFor();
+ const tampered = yaml.replace("listen: 0.0.0.0:443", "listen: 0.0.0.0:8443");
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/listen must be/);
+ });
+
+ test("подменён trafficStats listen", () => {
+ const { config, yaml } = renderFor();
+ const tampered = yaml.replace("127.0.0.1:36712", "0.0.0.0:36712");
+ expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/trafficStats\.listen/);
+ });
+
+ test("невалидный YAML отвергается", () => {
+ const { config } = renderFor();
+ expect(() => assertHysteriaConfigMatchesProfile("just a string", config)).toThrow();
+ });
+});
diff --git a/orchestrator/test/env.test.ts b/orchestrator/test/env.test.ts
new file mode 100644
index 0000000..5661f50
--- /dev/null
+++ b/orchestrator/test/env.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, test } from "bun:test";
+import { parseRuntimeEnv, renderRuntimeEnv } from "../src/config/env";
+import {
+ GECKO_DEFAULT_MAX_PACKET_SIZE,
+ GECKO_DEFAULT_MIN_PACKET_SIZE,
+ HY2XS_CONFIG_SCHEMA_VERSION,
+ normalizeHysteriaObfsType,
+ validateGeckoPacketSizes
+} from "../src/config/profile";
+import { baselineConfig, envText } from "./fixtures";
+
+describe("obfs type resolution", () => {
+ test("новая установка без явного значения выбирает gecko", () => {
+ const config = parseRuntimeEnv(envText({ HY2XS_HYSTERIA_OBFS_TYPE: null }));
+ expect(config.hysteriaObfsType).toBe("gecko");
+ });
+
+ test("явный gecko принимается", () => {
+ expect(baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" }).hysteriaObfsType).toBe("gecko");
+ });
+
+ test("явный salamander принимается как compatibility fallback", () => {
+ expect(baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" }).hysteriaObfsType).toBe("salamander");
+ });
+
+ test("неизвестный тип отклоняется", () => {
+ expect(() => baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "none" })).toThrow(
+ /invalid HY2XS_HYSTERIA_OBFS_TYPE/
+ );
+ });
+
+ test("тип нечувствителен к окружающим пробелам, но не к регистру", () => {
+ expect(normalizeHysteriaObfsType(" gecko ")).toBe("gecko");
+ expect(() => normalizeHysteriaObfsType("Gecko")).toThrow(/invalid HY2XS_HYSTERIA_OBFS_TYPE/);
+ });
+
+ test("пустой obfs-пароль означает автогенерацию, а не пустое значение в конфиге", () => {
+ for (const raw of ["", "__GENERATE__"]) {
+ const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: raw });
+ expect(config.hysteriaObfsPassword).not.toBe("");
+ expect(config.hysteriaObfsPassword).not.toBe("__GENERATE__");
+ expect(config.hysteriaObfsPassword.length).toBeGreaterThanOrEqual(24);
+ }
+ });
+
+ test("два разбора генерируют разные пароли", () => {
+ const first = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: "__GENERATE__" });
+ const second = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: "__GENERATE__" });
+ expect(first.hysteriaObfsPassword).not.toBe(second.hysteriaObfsPassword);
+ });
+
+ test("obfs-пароль с кавычкой или переводом строки отклоняется", () => {
+ expect(() => baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: 'pa"ss' })).toThrow(
+ /forbidden characters/
+ );
+ });
+});
+
+describe("gecko packet sizes", () => {
+ test("baseline фиксирует upstream defaults 512/1200", () => {
+ const config = baselineConfig();
+ expect(config.hysteriaGeckoMinPacketSize).toBe(512);
+ expect(config.hysteriaGeckoMaxPacketSize).toBe(1200);
+ expect(config.hysteriaGeckoMinPacketSize).toBe(GECKO_DEFAULT_MIN_PACKET_SIZE);
+ expect(config.hysteriaGeckoMaxPacketSize).toBe(GECKO_DEFAULT_MAX_PACKET_SIZE);
+ });
+
+ test("валидная пара принимается", () => {
+ expect(() => validateGeckoPacketSizes(512, 1200)).not.toThrow();
+ expect(() => validateGeckoPacketSizes(700, 700)).not.toThrow();
+ });
+
+ test("max < min отклоняется", () => {
+ expect(() => validateGeckoPacketSizes(1200, 512)).toThrow(/must be >= minPacketSize/);
+ });
+
+ test("max > 2048 отклоняется", () => {
+ expect(() => validateGeckoPacketSizes(512, 2049)).toThrow(/upstream limit is 2048/);
+ });
+
+ test("max == 2048 принимается как граничное значение", () => {
+ expect(() => validateGeckoPacketSizes(512, 2048)).not.toThrow();
+ });
+
+ test("неположительный min отклоняется", () => {
+ expect(() => validateGeckoPacketSizes(0, 1200)).toThrow(/positive integer/);
+ expect(() => validateGeckoPacketSizes(-1, 1200)).toThrow(/positive integer/);
+ });
+
+ test("нецелые значения отклоняются", () => {
+ expect(() => validateGeckoPacketSizes(512.5, 1200)).toThrow(/positive integer/);
+ });
+});
+
+describe("config schema version", () => {
+ test("отсутствие значения даёт текущую схему", () => {
+ const config = parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }));
+ expect(config.configSchemaVersion).toBe(HY2XS_CONFIG_SCHEMA_VERSION);
+ });
+
+ test("схема v0/v1 отклоняется с указанием на чистую установку", () => {
+ expect(() => baselineConfig({ HY2XS_CONFIG_SCHEMA_VERSION: "1" })).toThrow(
+ /requires a clean installation/
+ );
+ });
+
+ test("нечисловая схема отклоняется", () => {
+ expect(() => baselineConfig({ HY2XS_CONFIG_SCHEMA_VERSION: "two" })).toThrow(
+ /invalid HY2XS_CONFIG_SCHEMA_VERSION/
+ );
+ });
+});
+
+describe("renderRuntimeEnv round-trip", () => {
+ // Регрессия: renderRuntimeEnv раньше печатал литерал `salamander`, из-за чего
+ // записанный на диск runtime-конфиг расходился с разобранным.
+ test("gecko переживает render → parse без потерь", () => {
+ const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
+ const reparsed = parseRuntimeEnv(renderRuntimeEnv(config));
+ expect(reparsed).toEqual(config);
+ });
+
+ test("salamander переживает render → parse без подмены на дефолт", () => {
+ const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
+ const rendered = renderRuntimeEnv(config);
+ expect(rendered).toContain("HY2XS_HYSTERIA_OBFS_TYPE=salamander");
+ expect(rendered).not.toContain("HY2XS_HYSTERIA_OBFS_TYPE=gecko");
+ expect(parseRuntimeEnv(rendered)).toEqual(config);
+ });
+
+ test("render не теряет ни одно поле runtime-конфига", () => {
+ const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
+ const reparsed = parseRuntimeEnv(renderRuntimeEnv(config));
+ for (const key of Object.keys(config) as (keyof typeof config)[]) {
+ expect(reparsed[key]).toEqual(config[key]);
+ }
+ });
+
+ test("схема конфигурации попадает в runtime-файл", () => {
+ expect(renderRuntimeEnv(baselineConfig())).toContain(
+ `HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}`
+ );
+ });
+});
+
+describe("остальные production-инварианты", () => {
+ test("IPv6 остаётся запрещённым", () => {
+ expect(() => baselineConfig({ HY2XS_IPV6_ENABLED: "true" })).toThrow(/IPv4-only/);
+ });
+
+ test("auth mode зафиксирован в http", () => {
+ expect(() => baselineConfig({ HY2XS_HYSTERIA_AUTH_MODE: "password" })).toThrow(
+ /HY2XS_HYSTERIA_AUTH_MODE is fixed/
+ );
+ });
+
+ test("ACME DNS challenge пока запрещён в production-профиле", () => {
+ expect(() => baselineConfig({ HY2XS_ACME_TYPE: "dns" })).toThrow(/not supported in production profile/);
+ });
+});
diff --git a/orchestrator/test/fixtures.ts b/orchestrator/test/fixtures.ts
new file mode 100644
index 0000000..072a302
--- /dev/null
+++ b/orchestrator/test/fixtures.ts
@@ -0,0 +1,102 @@
+import type { RuntimeConfig, RuntimeContext } from "../src/types/context";
+import { parseRuntimeEnv } from "../src/config/env";
+
+/**
+ * Минимальный валидный packaged baseline. Тесты меняют только те строки,
+ * которые проверяют, поэтому «валидный по умолчанию» набор живёт в одном месте.
+ */
+export const BASELINE_ENV_LINES: readonly string[] = [
+ "HY2XS_CONFIG_SCHEMA_VERSION=2",
+ "HY2XS_IPV6_ENABLED=false",
+ "HY2XS_DOMAIN=vpn.example.com",
+ "HY2XS_DNS_AAAA_POLICY=strict",
+ "HY2XS_PUBLIC_HOST=vpn.example.com",
+ "HY2XS_PUBLIC_PORT=443",
+ "HY2XS_SSH_PORT=2323",
+ "HY2XS_FIREWALL_MODE=takeover",
+ "HY2XS_FIREWALL_STAGED_APPLY=true",
+ "HY2XS_UI_BIND_HOST=127.0.0.1",
+ "HY2XS_UI_PUBLIC_ACCESS=false",
+ "HY2XS_UI_PORT=8080",
+ "HY2XS_ADMIN_USER=hy2xsadmin",
+ "HY2XS_ADMIN_INITIAL_PASSWORD=initial-password",
+ "HY2XS_ADMIN_CON_PASS=connection-password",
+ "HY2XS_FORCE_PASSWORD_CHANGE=false",
+ "HY2XS_ALLOW_SELF_SIGNED_DEV=false",
+ "HY2XS_TLS_MODE=acme",
+ "HY2XS_ACME_TYPE=http",
+ "HY2XS_ACME_EMAIL=admin@example.com",
+ "HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt",
+ "HY2XS_TLS_KEY_PATH=/etc/hysteria/server.key",
+ "HY2XS_HYSTERIA_BIND_HOST=0.0.0.0",
+ "HY2XS_HYSTERIA_PORT=443",
+ "HY2XS_HYSTERIA_AUTH_MODE=http",
+ "HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1",
+ "HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712",
+ "HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=traffic-stats-secret",
+ "HY2XS_HYSTERIA_OBFS_TYPE=gecko",
+ "HY2XS_HYSTERIA_OBFS_PASSWORD=obfs-password",
+ "HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps",
+ "HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps",
+ "HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=false",
+ "HY2XS_HYSTERIA_CONFIG_PATH=/etc/hysteria/config.yaml",
+ "HY2XS_INSTALL_DIR=/opt/hy2xs-admin",
+ "HY2XS_DATA_DIR=/var/lib/hy2xs-admin",
+ "HY2XS_LOG_DIR=/var/log/hy2xs"
+];
+
+/**
+ * Собирает env-текст из baseline с переопределениями.
+ * Значение `null` удаляет ключ целиком (проверка поведения по умолчанию).
+ */
+export function envText(overrides: Record = {}): string {
+ const lines: string[] = [];
+ const applied = new Set();
+
+ for (const line of BASELINE_ENV_LINES) {
+ const key = line.slice(0, line.indexOf("="));
+ if (key in overrides) {
+ applied.add(key);
+ const value = overrides[key];
+ if (value === null) {
+ continue;
+ }
+ lines.push(`${key}=${value}`);
+ continue;
+ }
+ lines.push(line);
+ }
+
+ for (const [key, value] of Object.entries(overrides)) {
+ if (!applied.has(key) && value !== null) {
+ lines.push(`${key}=${value}`);
+ }
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+export function baselineConfig(overrides: Record = {}): RuntimeConfig {
+ return parseRuntimeEnv(envText(overrides));
+}
+
+export function testContext(config: RuntimeConfig): RuntimeContext {
+ return {
+ mode: "install",
+ options: {
+ packageDir: "/opt/hy2xs/package",
+ sourceConfigPath: "",
+ runtimeConfigPath: "/etc/hy2xs/hy2xs.env",
+ nonInteractive: true,
+ skipFirewall: false,
+ skipServiceStart: false,
+ skipSmoke: false
+ },
+ config,
+ packageVersion: "1.0.0",
+ packageBuildId: "test-build",
+ installDate: "2026-08-27T00:00:00.000Z",
+ hysteriaVersion: "v2.12.2",
+ hysteriaResolution: "latest-stable"
+ };
+}
diff --git a/orchestrator/test/hysteria-release.test.ts b/orchestrator/test/hysteria-release.test.ts
new file mode 100644
index 0000000..678feaf
--- /dev/null
+++ b/orchestrator/test/hysteria-release.test.ts
@@ -0,0 +1,220 @@
+import { describe, expect, test } from "bun:test";
+import {
+ HYSTERIA_LINUX_AMD64_ASSET,
+ compareVersionParts,
+ parseAppTag,
+ parseVersion,
+ renderLockEnv,
+ selectLatestStableRelease,
+ selectLinuxAmd64Asset,
+ selectReleaseByVersion,
+ type GithubRelease
+} from "../src/build/hysteriaRelease";
+
+/** Upstream отдаёт URL с неэкранированным слэшем в теге; берём его как есть. */
+function assetUrl(tag: string): string {
+ return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${HYSTERIA_LINUX_AMD64_ASSET}`;
+}
+
+function release(tag: string, overrides: Partial = {}): GithubRelease {
+ return {
+ tag_name: tag,
+ draft: false,
+ prerelease: false,
+ published_at: "2026-08-23T00:59:00Z",
+ assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl(tag) }],
+ ...overrides
+ };
+}
+
+/** Реалистичная выборка: stable, prerelease, draft, чужие семейства тегов. */
+const UPSTREAM_FIXTURE: GithubRelease[] = [
+ release("app/v2.13.0-rc1", { prerelease: true }),
+ release("app/v2.13.0", { prerelease: true }),
+ release("app/v2.14.0", { draft: true }),
+ release("app/v2.12.2"),
+ release("app/v2.12.1"),
+ release("app/v2.9.10"),
+ release("app/v2.9.2"),
+ release("core/v2.12.2"),
+ release("docs/v1.0.0"),
+ release("v2.12.2")
+];
+
+describe("selectLatestStableRelease", () => {
+ test("выбирает последнюю стабильную app-версию", () => {
+ const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE);
+ expect(resolved.version).toBe("v2.12.2");
+ expect(resolved.tag).toBe("app/v2.12.2");
+ });
+
+ test("игнорирует prerelease", () => {
+ const resolved = selectLatestStableRelease([release("app/v2.12.2"), release("app/v2.13.0", { prerelease: true })]);
+ expect(resolved.version).toBe("v2.12.2");
+ });
+
+ test("игнорирует draft", () => {
+ const resolved = selectLatestStableRelease([release("app/v2.12.2"), release("app/v2.13.0", { draft: true })]);
+ expect(resolved.version).toBe("v2.12.2");
+ });
+
+ test("игнорирует чужие семейства тегов", () => {
+ const resolved = selectLatestStableRelease([release("core/v9.9.9"), release("app/v2.12.2")]);
+ expect(resolved.version).toBe("v2.12.2");
+ });
+
+ test("игнорирует тег без префикса app/", () => {
+ expect(() => selectLatestStableRelease([release("v2.12.2")])).toThrow(/no stable/);
+ });
+
+ test("сравнение числовое, а не лексикографическое", () => {
+ const resolved = selectLatestStableRelease([release("app/v2.9.2"), release("app/v2.9.10")]);
+ expect(resolved.version).toBe("v2.9.10");
+ });
+
+ test("порядок элементов в ответе API не влияет на результат", () => {
+ const reversed = [...UPSTREAM_FIXTURE].reverse();
+ expect(selectLatestStableRelease(reversed).version).toBe("v2.12.2");
+ });
+
+ test("невалидный semver в теге отбрасывается", () => {
+ expect(() => selectLatestStableRelease([release("app/v2.12"), release("app/vNEXT")])).toThrow(
+ /no stable/
+ );
+ });
+
+ test("пустой список релизов даёт понятную ошибку", () => {
+ expect(() => selectLatestStableRelease([])).toThrow(/no stable/);
+ });
+
+ test("берёт browser_download_url ровно как отдал API, без пересборки строки", () => {
+ const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE);
+ expect(resolved.artifactUrl).toBe(
+ "https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hysteria-linux-amd64"
+ );
+ });
+
+ test("процентное кодирование в url upstream не переписывается", () => {
+ const encoded = "https://github.com/HyNetworks/hysteria/releases/download/app%2Fv2.12.2/hysteria-linux-amd64";
+ const resolved = selectLatestStableRelease([
+ release("app/v2.12.2", {
+ assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: encoded }]
+ })
+ ]);
+ expect(resolved.artifactUrl).toBe(encoded);
+ });
+});
+
+describe("selectLinuxAmd64Asset", () => {
+ test("отсутствующий linux-amd64 asset — ошибка", () => {
+ const broken = release("app/v2.12.2", {
+ assets: [{ name: "hysteria-linux-arm64", browser_download_url: assetUrl("app/v2.12.2") }]
+ });
+ expect(() => selectLatestStableRelease([broken])).toThrow(/has no hysteria-linux-amd64 asset/);
+ });
+
+ test("дублирующийся asset — ошибка, а не случайный выбор", () => {
+ const ambiguous = release("app/v2.12.2", {
+ assets: [
+ { name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl("app/v2.12.2") },
+ { name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "https://example.com/evil" }
+ ]
+ });
+ expect(() => selectLatestStableRelease([ambiguous])).toThrow(/ambiguous/);
+ });
+
+ test("non-https url отклоняется", () => {
+ const insecure = release("app/v2.12.2", {
+ assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "http://github.com/x" }]
+ });
+ expect(() => selectLinuxAmd64Asset(insecure)).toThrow(/not https/);
+ });
+
+ test("пустой url отклоняется", () => {
+ const empty = release("app/v2.12.2", {
+ assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "" }]
+ });
+ expect(() => selectLinuxAmd64Asset(empty)).toThrow(/empty asset download url/);
+ });
+
+ test("частичное совпадение имени asset не принимается", () => {
+ const partial = release("app/v2.12.2", {
+ assets: [
+ { name: "hysteria-linux-amd64-avx", browser_download_url: assetUrl("app/v2.12.2") }
+ ]
+ });
+ expect(() => selectLinuxAmd64Asset(partial)).toThrow(/has no hysteria-linux-amd64 asset/);
+ });
+});
+
+describe("selectReleaseByVersion", () => {
+ test("находит явно запрошенную версию, даже если она не последняя", () => {
+ const resolved = selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.12.1");
+ expect(resolved.version).toBe("v2.12.1");
+ expect(resolved.tag).toBe("app/v2.12.1");
+ });
+
+ test("позволяет осознанно закрепить prerelease по явному запросу", () => {
+ const resolved = selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.13.0");
+ expect(resolved.tag).toBe("app/v2.13.0");
+ // при этом автоматический выбор её по-прежнему не берёт
+ expect(selectLatestStableRelease(UPSTREAM_FIXTURE).version).toBe("v2.12.2");
+ });
+
+ test("draft по явному запросу отклоняется", () => {
+ expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.14.0")).toThrow(/draft release/);
+ });
+
+ test("несуществующая версия даёт ошибку", () => {
+ expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, "v9.9.9")).toThrow(/was not found/);
+ });
+
+ test("невалидный формат override отклоняется", () => {
+ for (const bad of ["2.12.2", "v2.12", "latest", ""]) {
+ expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, bad)).toThrow(/invalid Hysteria version override/);
+ }
+ });
+});
+
+describe("парсинг версий", () => {
+ test("parseAppTag", () => {
+ expect(parseAppTag("app/v2.12.2")?.version).toBe("v2.12.2");
+ expect(parseAppTag(" app/v2.12.2 ")?.version).toBe("v2.12.2");
+ expect(parseAppTag("app/v2.12.2-rc1")).toBeNull();
+ expect(parseAppTag("app/2.12.2")).toBeNull();
+ });
+
+ test("parseVersion", () => {
+ expect(parseVersion("v2.12.2")).toEqual([2, 12, 2]);
+ expect(parseVersion("v2.12.2-rc1")).toBeNull();
+ });
+
+ test("compareVersionParts", () => {
+ expect(compareVersionParts([2, 12, 2], [2, 12, 1])).toBe(1);
+ expect(compareVersionParts([2, 9, 10], [2, 9, 2])).toBe(1);
+ expect(compareVersionParts([2, 12, 2], [2, 12, 2])).toBe(0);
+ expect(compareVersionParts([1, 99, 99], [2, 0, 0])).toBe(-1);
+ });
+});
+
+describe("renderLockEnv", () => {
+ const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE);
+ const sha = "a".repeat(64);
+
+ test("содержит замороженные версию, url и sha256", () => {
+ const rendered = renderLockEnv(resolved, sha, "latest-stable", "2026-08-27T00:00:00Z");
+ expect(rendered).toContain("HYSTERIA_VERSION=v2.12.2");
+ expect(rendered).toContain(`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`);
+ expect(rendered).toContain(`HYSTERIA_ARTIFACT_SHA256=${sha}`);
+ expect(rendered).toContain("HYSTERIA_RESOLUTION=latest-stable");
+ expect(rendered).toContain("HYSTERIA_RESOLVED_AT=2026-08-27T00:00:00Z");
+ });
+
+ test("невалидный sha256 отклоняется", () => {
+ for (const bad of ["", "abc", "replace-with-real-sha256", "A".repeat(64), "z".repeat(64)]) {
+ expect(() => renderLockEnv(resolved, bad, "latest-stable", "2026-08-27T00:00:00Z")).toThrow(
+ /invalid Hysteria artifact sha256/
+ );
+ }
+ });
+});
diff --git a/orchestrator/test/release-rollover.test.ts b/orchestrator/test/release-rollover.test.ts
new file mode 100644
index 0000000..01ffea4
--- /dev/null
+++ b/orchestrator/test/release-rollover.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import {
+ HYSTERIA_LINUX_AMD64_ASSET,
+ selectLatestStableRelease,
+ type GithubRelease
+} from "../src/build/hysteriaRelease";
+
+function release(tag: string): GithubRelease {
+ return {
+ tag_name: tag,
+ draft: false,
+ prerelease: false,
+ published_at: "2026-08-23T00:59:00Z",
+ assets: [
+ {
+ name: HYSTERIA_LINUX_AMD64_ASSET,
+ browser_download_url: `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${HYSTERIA_LINUX_AMD64_ASSET}`
+ }
+ ]
+ };
+}
+
+function srcFile(...parts: string[]): string {
+ return readFileSync(join(import.meta.dir, "..", "src", ...parts), "utf8");
+}
+
+describe("release rollover", () => {
+ // Пакет A собран сегодня, пакет B — завтра. Выход новой upstream-версии
+ // не должен менять содержимое уже собранного пакета A.
+ const upstreamToday = [release("app/v2.12.1"), release("app/v2.12.2")];
+ const upstreamTomorrow = [...upstreamToday, release("app/v2.12.3")];
+
+ test("сборка сегодня закрепляет 2.12.2", () => {
+ expect(selectLatestStableRelease(upstreamToday).version).toBe("v2.12.2");
+ });
+
+ test("сборка завтра закрепляет 2.12.3", () => {
+ expect(selectLatestStableRelease(upstreamTomorrow).version).toBe("v2.12.3");
+ });
+
+ test("резолв чистый: тот же список всегда даёт тот же результат", () => {
+ const first = selectLatestStableRelease(upstreamToday);
+ const second = selectLatestStableRelease(upstreamToday);
+ expect(first).toEqual(second);
+ });
+});
+
+describe("install-time никогда не резолвит latest", () => {
+ // Гарантия acceptance-критерия: "latest" относится к моменту сборки,
+ // поэтому переустановка старого пакета обязана ставить старую версию.
+ const installTimeSources = [
+ ["commands", "install.ts"],
+ ["steps", "hysteria.ts"],
+ ["lib", "packageMeta.ts"],
+ ["commands", "reconfigure.ts"]
+ ];
+
+ test("install-time код не импортирует build-time резолвер", () => {
+ for (const parts of installTimeSources) {
+ expect(srcFile(...parts)).not.toContain("build/hysteriaRelease");
+ }
+ });
+
+ test("install-time код не обращается к upstream API и не использует moving latest", () => {
+ for (const parts of installTimeSources) {
+ const source = srcFile(...parts);
+ expect(source).not.toContain("api.github.com");
+ expect(source).not.toContain("download.hysteria.network");
+ expect(source).not.toMatch(/releases\/latest/);
+ }
+ });
+
+ test("установка берёт версию, url и sha256 только из metadata пакета", () => {
+ const install = srcFile("commands", "install.ts");
+ expect(install).toContain('readPackageValue(options.packageDir, "hysteria.version"');
+ expect(install).toContain('readPackageValue(options.packageDir, "hysteria.url"');
+ expect(install).toContain('readPackageValue(options.packageDir, "hysteria.sha256"');
+ });
+
+ test("отсутствие любой части lock-метаданных прерывает установку", () => {
+ expect(srcFile("commands", "install.ts")).toContain("missing Hysteria lock metadata in package");
+ });
+
+ test("установленный бинарник сверяется с закреплённой версией", () => {
+ const step = srcFile("steps", "hysteria.ts");
+ expect(step).toContain("installed Hysteria version mismatch");
+ expect(step).toContain("sha256sum -c -");
+ });
+});
diff --git a/orchestrator/test/render-config.test.ts b/orchestrator/test/render-config.test.ts
new file mode 100644
index 0000000..84c8420
--- /dev/null
+++ b/orchestrator/test/render-config.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { renderHysteriaConfig } from "../src/steps/config";
+import { renderObfsBlock } from "../src/config/profile";
+import { baselineConfig, testContext } from "./fixtures";
+
+const TEMPLATE = readFileSync(
+ join(import.meta.dir, "..", "..", "package", "templates", "hysteria", "config.yaml.tpl"),
+ "utf8"
+);
+
+function render(overrides: Record = {}): string {
+ return renderHysteriaConfig(testContext(baselineConfig(overrides)), TEMPLATE);
+}
+
+describe("obfs block", () => {
+ test("gecko рендерит только gecko-подблок", () => {
+ const yaml = render({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
+ expect(yaml).toContain("obfs:\n type: gecko\n gecko:");
+ expect(yaml).toContain("minPacketSize: 512");
+ expect(yaml).toContain("maxPacketSize: 1200");
+ expect(yaml).not.toContain("salamander");
+ });
+
+ test("salamander рендерит только salamander-подблок", () => {
+ const yaml = render({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
+ expect(yaml).toContain("obfs:\n type: salamander\n salamander:");
+ expect(yaml).not.toContain("gecko");
+ expect(yaml).not.toContain("minPacketSize");
+ });
+
+ test("в конфиге никогда нет двух подтипов obfs одновременно", () => {
+ for (const obfsType of ["gecko", "salamander"]) {
+ const yaml = render({ HY2XS_HYSTERIA_OBFS_TYPE: obfsType });
+ const subtypes = [" gecko:", " salamander:"].filter((marker) => yaml.includes(marker));
+ expect(subtypes).toHaveLength(1);
+ }
+ });
+
+ test("obfs-пароль экранируется кавычками и попадает в оба профиля", () => {
+ for (const obfsType of ["gecko", "salamander"]) {
+ const yaml = render({
+ HY2XS_HYSTERIA_OBFS_TYPE: obfsType,
+ HY2XS_HYSTERIA_OBFS_PASSWORD: "p@ss:w#rd with spaces"
+ });
+ expect(yaml).toContain('password: "p@ss:w#rd with spaces"');
+ }
+ });
+
+ test("рендер блока защищается от YAML-инъекции даже в обход env-валидации", () => {
+ const config = baselineConfig();
+ const injected = { ...config, hysteriaObfsPassword: 'x"\nlisten: 0.0.0.0:1' };
+ expect(() => renderObfsBlock(injected)).toThrow(/forbidden characters/);
+ });
+
+ test("рендер отклоняет невалидные gecko-размеры, даже если они пришли из обхода env", () => {
+ const config = baselineConfig();
+ expect(() => renderObfsBlock({ ...config, hysteriaGeckoMaxPacketSize: 4096 })).toThrow(
+ /upstream limit is 2048/
+ );
+ });
+});
+
+describe("современный серверный baseline", () => {
+ test("bandwidth содержит явный disableLossCompensation: false", () => {
+ expect(render()).toContain("disableLossCompensation: false");
+ });
+
+ test("congestion фиксирует bbr/standard", () => {
+ const yaml = render();
+ expect(yaml).toContain("congestion:\n type: bbr\n bbrProfile: standard");
+ });
+
+ test("quic фиксирует disableStatelessReset: false", () => {
+ expect(render()).toContain("disableStatelessReset: false");
+ });
+
+ test("quic содержит полный набор baseline-полей", () => {
+ const yaml = render();
+ for (const field of [
+ "initStreamReceiveWindow: 8388608",
+ "maxStreamReceiveWindow: 8388608",
+ "initConnReceiveWindow: 20971520",
+ "maxConnReceiveWindow: 20971520",
+ "maxIdleTimeout: 30s",
+ "maxIncomingStreams: 1024",
+ "disablePathMTUDiscovery: false"
+ ]) {
+ expect(yaml).toContain(field);
+ }
+ });
+
+ test("bandwidth-политика 50/50 сохраняется", () => {
+ const yaml = render();
+ expect(yaml).toContain('up: "50 mbps"');
+ expect(yaml).toContain('down: "50 mbps"');
+ expect(yaml).toContain("ignoreClientBandwidth: false");
+ });
+});
+
+describe("TLS-режимы", () => {
+ test("acme рендерит только acme-блок", () => {
+ const yaml = render({ HY2XS_TLS_MODE: "acme" });
+ expect(yaml).toMatch(/^acme:/m);
+ expect(yaml).not.toMatch(/^tls:/m);
+ expect(yaml).toContain("insecure: false");
+ });
+
+ test("file рендерит только tls-блок", () => {
+ const yaml = render({ HY2XS_TLS_MODE: "file" });
+ expect(yaml).toMatch(/^tls:/m);
+ expect(yaml).not.toMatch(/^acme:/m);
+ });
+
+ test("self_signed_dev включает insecure для локального auth", () => {
+ const yaml = render({
+ HY2XS_TLS_MODE: "self_signed_dev",
+ HY2XS_ALLOW_SELF_SIGNED_DEV: "true"
+ });
+ expect(yaml).toContain("insecure: true");
+ });
+});
+
+describe("шаблон", () => {
+ test("не осталось незаполненных плейсхолдеров", () => {
+ expect(render()).not.toMatch(/\{\{\s*[A-Z0-9_]+\s*\}\}/);
+ });
+
+ test("auth URL содержит machine access token", () => {
+ expect(render()).toContain(
+ "/hui/hysteria2/auth?access_token=traffic-stats-secret"
+ );
+ });
+
+ test("шаблон не содержит захардкоженного типа обфускации", () => {
+ expect(TEMPLATE).not.toContain("salamander");
+ expect(TEMPLATE).not.toContain("gecko");
+ expect(TEMPLATE).toContain("{{OBFS_BLOCK}}");
+ });
+});
diff --git a/orchestrator/tools/render-canonical-config.ts b/orchestrator/tools/render-canonical-config.ts
new file mode 100644
index 0000000..97d07f7
--- /dev/null
+++ b/orchestrator/tools/render-canonical-config.ts
@@ -0,0 +1,171 @@
+/**
+ * Build-time CLI: рендерит канонический серверный конфиг HY2XS тем же кодом,
+ * который работает на target-сервере.
+ *
+ * Используется compatibility gate: получившийся YAML скармливается реальному
+ * upstream-бинарнику Hysteria до того, как будет создан release package.
+ *
+ * bun run tools/render-canonical-config.ts \
+ * --package-dir . --obfs gecko --tls-mode file \
+ * --cert /tmp/x.crt --key /tmp/x.key --out /tmp/config.yaml
+ */
+
+import { parseRuntimeEnv } from "../src/config/env";
+import { HYSTERIA_OBFS_TYPES } from "../src/config/profile";
+import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config";
+import type { RuntimeContext } from "../src/types/context";
+
+type Options = {
+ packageDir: string;
+ configPath: string;
+ templatePath: string;
+ obfs: string;
+ tlsMode: string;
+ certPath: string;
+ keyPath: string;
+ hysteriaPort: string;
+ trafficStatsPort: string;
+ outPath: string;
+};
+
+function fail(message: string): never {
+ console.error(`[hy2xs-build] ERROR: ${message}`);
+ process.exit(1);
+}
+
+function parseArgs(argv: string[]): Options {
+ const options: Options = {
+ packageDir: "",
+ configPath: "",
+ templatePath: "",
+ obfs: "gecko",
+ tlsMode: "file",
+ certPath: "",
+ keyPath: "",
+ hysteriaPort: "",
+ trafficStatsPort: "",
+ outPath: ""
+ };
+
+ const flags: Record = {
+ "--package-dir": "packageDir",
+ "--config": "configPath",
+ "--template": "templatePath",
+ "--obfs": "obfs",
+ "--tls-mode": "tlsMode",
+ "--cert": "certPath",
+ "--key": "keyPath",
+ "--port": "hysteriaPort",
+ "--traffic-stats-port": "trafficStatsPort",
+ "--out": "outPath"
+ };
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const key = flags[argv[i]];
+ if (!key) {
+ fail(`unknown argument: ${argv[i]}`);
+ }
+ const value = argv[i + 1];
+ if (value === undefined || value.startsWith("--")) {
+ fail(`missing value for ${argv[i]}`);
+ }
+ options[key] = value;
+ i += 1;
+ }
+
+ if (!options.packageDir) {
+ fail("missing --package-dir");
+ }
+ if (!options.outPath) {
+ fail("missing --out");
+ }
+ if (!(HYSTERIA_OBFS_TYPES as readonly string[]).includes(options.obfs)) {
+ fail(`unsupported --obfs value: ${options.obfs}`);
+ }
+
+ options.configPath ||= `${options.packageDir}/config/hy2xs.env`;
+ options.templatePath ||= hysteriaConfigTemplatePath(options.packageDir);
+
+ return options;
+}
+
+function overrideEnv(source: string, overrides: Record): string {
+ const pending = new Map(Object.entries(overrides));
+ const lines = source.split(/\r?\n/).map((line) => {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) {
+ return line;
+ }
+ const key = trimmed.slice(0, trimmed.indexOf("="));
+ if (pending.has(key)) {
+ const value = pending.get(key) as string;
+ pending.delete(key);
+ return `${key}=${value}`;
+ }
+ return line;
+ });
+
+ for (const [key, value] of pending) {
+ lines.push(`${key}=${value}`);
+ }
+
+ return lines.join("\n");
+}
+
+async function main(): Promise {
+ const options = parseArgs(Bun.argv.slice(2));
+
+ const overrides: Record = {
+ HY2XS_HYSTERIA_OBFS_TYPE: options.obfs,
+ HY2XS_TLS_MODE: options.tlsMode,
+ // Compatibility gate работает офлайн и не должен зависеть от секретов пакета.
+ HY2XS_HYSTERIA_OBFS_PASSWORD: "hy2xs-compat-gate-obfs-password",
+ HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET: "hy2xs-compat-gate-traffic-secret",
+ HY2XS_ADMIN_INITIAL_PASSWORD: "hy2xs-compat-gate-admin-password",
+ HY2XS_ADMIN_CON_PASS: "hy2xs-compat-gate-con-password"
+ };
+
+ if (options.certPath) {
+ overrides.HY2XS_TLS_CERT_PATH = options.certPath;
+ }
+ if (options.keyPath) {
+ overrides.HY2XS_TLS_KEY_PATH = options.keyPath;
+ }
+ if (options.hysteriaPort) {
+ overrides.HY2XS_HYSTERIA_PORT = options.hysteriaPort;
+ }
+ if (options.trafficStatsPort) {
+ overrides.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT = options.trafficStatsPort;
+ }
+
+ const sourceEnv = await Bun.file(options.configPath).text();
+ const config = parseRuntimeEnv(overrideEnv(sourceEnv, overrides));
+
+ const context = {
+ mode: "install",
+ options: {
+ packageDir: options.packageDir,
+ sourceConfigPath: options.configPath,
+ runtimeConfigPath: "/etc/hy2xs/hy2xs.env",
+ nonInteractive: true,
+ skipFirewall: true,
+ skipServiceStart: true,
+ skipSmoke: true
+ },
+ config,
+ packageVersion: "compat-gate",
+ packageBuildId: "compat-gate",
+ installDate: new Date().toISOString(),
+ hysteriaVersion: "compat-gate",
+ hysteriaResolution: "compat-gate"
+ } satisfies RuntimeContext;
+
+ const template = await Bun.file(options.templatePath).text();
+ await Bun.write(options.outPath, renderHysteriaConfig(context, template));
+}
+
+try {
+ await main();
+} catch (error) {
+ fail(error instanceof Error ? error.message : String(error));
+}
diff --git a/orchestrator/tools/resolve-hysteria.ts b/orchestrator/tools/resolve-hysteria.ts
new file mode 100644
index 0000000..271675e
--- /dev/null
+++ b/orchestrator/tools/resolve-hysteria.ts
@@ -0,0 +1,124 @@
+/**
+ * Build-time CLI: определяет upstream-релиз Hysteria и печатает env-строки.
+ *
+ * Запускается только на build machine из tools/build/lib/hysteria.sh.
+ * Target-сервер этот код никогда не выполняет.
+ *
+ * bun run tools/resolve-hysteria.ts --channel stable
+ * bun run tools/resolve-hysteria.ts --channel stable --version v2.12.2
+ */
+
+import {
+ HYSTERIA_UPSTREAM_REPO,
+ selectLatestStableRelease,
+ selectReleaseByVersion,
+ type GithubRelease,
+ type ResolvedHysteriaRelease
+} from "../src/build/hysteriaRelease";
+
+const RELEASES_PER_PAGE = 100;
+const MAX_PAGES = 5;
+
+function fail(message: string): never {
+ console.error(`[hy2xs-build] ERROR: ${message}`);
+ process.exit(1);
+}
+
+function parseArgs(argv: string[]): { channel: string; version: string } {
+ let channel = "stable";
+ let version = "";
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === "--channel") {
+ channel = argv[i + 1] ?? "";
+ i += 1;
+ continue;
+ }
+ if (arg === "--version") {
+ version = argv[i + 1] ?? "";
+ i += 1;
+ continue;
+ }
+ fail(`unknown argument: ${arg}`);
+ }
+
+ if (channel !== "stable") {
+ fail(`unsupported HYSTERIA_CHANNEL for resolution: ${channel} (expected stable)`);
+ }
+
+ return { channel, version: version.trim() };
+}
+
+async function fetchReleases(): Promise {
+ const headers: Record = {
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ "User-Agent": "hy2xs-build"
+ };
+
+ const token = (process.env.GITHUB_TOKEN ?? "").trim();
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+
+ const releases: GithubRelease[] = [];
+
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
+ const url = `https://api.github.com/repos/${HYSTERIA_UPSTREAM_REPO}/releases?per_page=${RELEASES_PER_PAGE}&page=${page}`;
+ const response = await fetch(url, { headers });
+
+ if (response.status === 403 || response.status === 429) {
+ fail(
+ `GitHub API rate limit reached (HTTP ${response.status}). ` +
+ "Set GITHUB_TOKEN, or build with HYSTERIA_CHANNEL=pinned."
+ );
+ }
+ if (!response.ok) {
+ fail(`GitHub API request failed: HTTP ${response.status} for ${url}`);
+ }
+
+ const payload = (await response.json()) as unknown;
+ if (!Array.isArray(payload)) {
+ fail(`unexpected GitHub API payload for ${url}`);
+ }
+
+ releases.push(...(payload as GithubRelease[]));
+
+ if (payload.length < RELEASES_PER_PAGE) {
+ break;
+ }
+ }
+
+ if (releases.length === 0) {
+ fail(`no releases returned for ${HYSTERIA_UPSTREAM_REPO}`);
+ }
+
+ return releases;
+}
+
+function emit(resolved: ResolvedHysteriaRelease, resolution: string): void {
+ // Значения читаются bash через eval-free парсинг, поэтому печатаем по строке.
+ console.log(`HYSTERIA_VERSION=${resolved.version}`);
+ console.log(`HYSTERIA_TAG=${resolved.tag}`);
+ console.log(`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`);
+ console.log(`HYSTERIA_PUBLISHED_AT=${resolved.publishedAt}`);
+ console.log(`HYSTERIA_RESOLUTION=${resolution}`);
+}
+
+async function main(): Promise {
+ const { version } = parseArgs(Bun.argv.slice(2));
+ const releases = await fetchReleases();
+
+ try {
+ if (version) {
+ emit(selectReleaseByVersion(releases, version), "override");
+ return;
+ }
+ emit(selectLatestStableRelease(releases), "latest-stable");
+ } catch (error) {
+ fail(error instanceof Error ? error.message : String(error));
+ }
+}
+
+await main();
diff --git a/package/config/hy2xs.env b/package/config/hy2xs.env
index a41e896..0f924d5 100644
--- a/package/config/hy2xs.env
+++ b/package/config/hy2xs.env
@@ -1,4 +1,7 @@
# HY2XS canonical production runtime config (packaged baseline)
+# Версия схемы конфигурации HY2XS. Пакет понимает только эту версию;
+# установка поверх сервера со схемой v0 не поддерживается — нужна чистая установка.
+HY2XS_CONFIG_SCHEMA_VERSION=2
HY2XS_IPV6_ENABLED=false
HY2XS_DOMAIN=fi.api.withen.pro
HY2XS_DNS_AAAA_POLICY=strict
@@ -27,8 +30,10 @@ HY2XS_HYSTERIA_AUTH_MODE=http
HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1
HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712
HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=__GENERATE__
-# fixed in production profile: must remain salamander
-HY2XS_HYSTERIA_OBFS_TYPE=salamander
+# gecko | salamander. Gecko — production default HY2XS (upstream помечает его experimental).
+# Salamander остаётся полностью поддержанным compatibility fallback.
+# Смена значения меняет wire-совместимость: клиентам нужна новая ссылка.
+HY2XS_HYSTERIA_OBFS_TYPE=gecko
HY2XS_HYSTERIA_OBFS_PASSWORD=__GENERATE__
HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps
HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps
diff --git a/package/templates/env/post-install.env.tpl b/package/templates/env/post-install.env.tpl
index a753610..6b573b7 100644
--- a/package/templates/env/post-install.env.tpl
+++ b/package/templates/env/post-install.env.tpl
@@ -6,6 +6,7 @@ DEPLOY_TIMESTAMP={{LAST_APPLY_DATE}}
PACKAGE_NAME=hy2xs-install-package
PACKAGE_BUILD_ID={{PACKAGE_BUILD_ID}}
PACKAGE_VERSION={{PACKAGE_VERSION}}
+HY2XS_CONFIG_SCHEMA_VERSION={{CONFIG_SCHEMA_VERSION}}
ORCH_SOURCE_STACK=bun-typescript
ORCH_BUILD_MODE=bun-compile
@@ -21,6 +22,7 @@ HY2XS_FIREWALL_STAGED_APPLY={{FIREWALL_STAGED_APPLY}}
HY2_SOURCE=official-upstream
HY2_VERSION={{HYSTERIA_VERSION}}
+HY2_RESOLUTION={{HYSTERIA_RESOLUTION}}
HY2_TLS_MODE={{TLS_MODE}}
HY2_ACME_EMAIL={{ACME_EMAIL}}
HY2_TLS_CERT_PATH={{TLS_CERT_PATH}}
@@ -30,11 +32,17 @@ HY2_PORT={{HYSTERIA_PORT}}
HY2_AUTH_MODE=http
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_TYPE={{OBFS_TYPE}}
HY2_OBFS_PASSWORD={{HYSTERIA_OBFS_PASSWORD}}
+HY2_GECKO_MIN_PACKET_SIZE={{GECKO_MIN_PACKET_SIZE}}
+HY2_GECKO_MAX_PACKET_SIZE={{GECKO_MAX_PACKET_SIZE}}
HY2_BANDWIDTH_UP={{BANDWIDTH_UP}}
HY2_BANDWIDTH_DOWN={{BANDWIDTH_DOWN}}
+HY2_DISABLE_LOSS_COMPENSATION={{DISABLE_LOSS_COMPENSATION}}
HY2_IGNORE_CLIENT_BANDWIDTH={{IGNORE_CLIENT_BANDWIDTH}}
+HY2_CONGESTION_TYPE={{CONGESTION_TYPE}}
+HY2_BBR_PROFILE={{BBR_PROFILE}}
+HY2_DISABLE_STATELESS_RESET={{DISABLE_STATELESS_RESET}}
HY2_CONFIG_PATH=/etc/hysteria/config.yaml
HY2XS_ADMIN_ENABLED=true
diff --git a/package/templates/hysteria/config.yaml.tpl b/package/templates/hysteria/config.yaml.tpl
index 66ecf30..0ac47bb 100644
--- a/package/templates/hysteria/config.yaml.tpl
+++ b/package/templates/hysteria/config.yaml.tpl
@@ -9,23 +9,19 @@ auth:
url: http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}
insecure: {{AUTH_INSECURE}}
-obfs:
- type: salamander
- salamander:
- password: "{{HYSTERIA_OBFS_PASSWORD}}"
+{{OBFS_BLOCK}}
bandwidth:
up: "{{BANDWIDTH_UP}}"
down: "{{BANDWIDTH_DOWN}}"
+ disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}
ignoreClientBandwidth: {{IGNORE_CLIENT_BANDWIDTH}}
+{{CONGESTION_BLOCK}}
+
trafficStats:
listen: {{HYSTERIA_API_HOST}}:{{HYSTERIA_API_PORT}}
secret: "{{HYSTERIA_API_SECRET}}"
-quic:
- initStreamReceiveWindow: 8388608
- maxStreamReceiveWindow: 8388608
- initConnReceiveWindow: 20971520
- maxConnReceiveWindow: 20971520
+{{QUIC_BLOCK}}
diff --git a/tools/build/README.md b/tools/build/README.md
index 106ca25..c37f185 100644
--- a/tools/build/README.md
+++ b/tools/build/README.md
@@ -20,7 +20,10 @@ dist/hy2xs-install-.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-.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-.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=`
- `BUN_ARCHIVE_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:
diff --git a/tools/build/build.sh b/tools/build/build.sh
index 9e3ff71..c9910eb 100755
--- a/tools/build/build.sh
+++ b/tools/build/build.sh
@@ -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"
diff --git a/tools/build/hysteria-lock.env b/tools/build/hysteria-lock.env
index b637015..40b08f6 100644
--- a/tools/build/hysteria-lock.env
+++ b/tools/build/hysteria-lock.env
@@ -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
diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh
index 2fcc7fe..8cac914 100644
--- a/tools/build/lib/acceptance.sh
+++ b/tools/build/lib/acceptance.sh
@@ -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"
diff --git a/tools/build/lib/hysteria.sh b/tools/build/lib/hysteria.sh
new file mode 100644
index 0000000..56f0526
--- /dev/null
+++ b/tools/build/lib/hysteria.sh
@@ -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" <"$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"
diff --git a/tools/build/lib/verify.sh b/tools/build/lib/verify.sh
index 4e77b9d..3973924 100644
--- a/tools/build/lib/verify.sh
+++ b/tools/build/lib/verify.sh
@@ -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)"
diff --git a/tools/test/e2e-hysteria.sh b/tools/test/e2e-hysteria.sh
new file mode 100755
index 0000000..5ee58d0
--- /dev/null
+++ b/tools/test/e2e-hysteria.sh
@@ -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" < 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" <"$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" <>"$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(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/}"
+ 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 "$@"