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

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

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

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

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

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

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

Прочее: CHANGELOG.md, .gitattributes (LF для target-side файлов),
документация на русском.
This commit is contained in:
2026-08-27 08:15:02 +05:00
parent 0205334cd8
commit ddf0ddf71e
53 changed files with 4827 additions and 291 deletions
+23
View File
@@ -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
+129
View File
@@ -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
+111 -8
View File
@@ -13,7 +13,9 @@
<a href="#возможности">Возможности</a> · <a href="#возможности">Возможности</a> ·
<a href="#быстрый-старт-для-нового-сервера">Быстрый старт</a> · <a href="#быстрый-старт-для-нового-сервера">Быстрый старт</a> ·
<a href="#конфигурация-hy2xsenv">Конфигурация</a> · <a href="#конфигурация-hy2xsenv">Конфигурация</a> ·
<a href="#версионная-политика-hysteria2">Версии</a> ·
<a href="#сборка-release-пакета">Сборка</a> · <a href="#сборка-release-пакета">Сборка</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
<a href="#лицензия">Лицензия</a> <a href="#лицензия">Лицензия</a>
</p> </p>
@@ -39,7 +41,7 @@ HY2XS подходит для сценария, где нужен один produ
HY2XS release‑пакет разворачивает и настраивает: HY2XS release‑пакет разворачивает и настраивает:
- официальный upstream‑бинарник Hysteria2, закреплённый в metadata пакета и проверяемый по SHA256; - официальный upstream‑бинарник Hysteria2: последняя стабильная версия выбирается при сборке пакета, закрепляется в его metadata и проверяется по SHA256;
- HY2XS admin — встроенную админ‑панель для управления users/peers, трафиком, конфигурацией, логами и состоянием сервера; - HY2XS admin — встроенную админ‑панель для управления users/peers, трафиком, конфигурацией, логами и состоянием сервера;
- systemd‑юнит `hysteria-server` для Hysteria2; - systemd‑юнит `hysteria-server` для Hysteria2;
- systemd‑юнит `hy2xs-admin` для админ‑панели; - systemd‑юнит `hy2xs-admin` для админ‑панели;
@@ -108,6 +110,8 @@ Windows и macOS можно использовать для разработки
- не включает IPv6production baseline; - не включает IPv6production baseline;
- не настраивает `sshd` автоматически; - не настраивает `sshd` автоматически;
- не предоставляет полноценный uninstall/update framework; - не предоставляет полноценный uninstall/update framework;
- не обновляет Hysteria2 на уже работающем сервере: `reconfigure` намеренно не является Hysteria updater;
- не мигрирует установки `0.x` на `1.0.0` — переход выполняется чистой установкой, см. [CHANGELOG](CHANGELOG.md);
- не выполняет сложную миграцию старых неизвестных состояний сервера; - не выполняет сложную миграцию старых неизвестных состояний сервера;
- не реализует Telegram‑бота, port hopping и универсальный accessdelivery workflow; - не реализует Telegram‑бота, port hopping и универсальный accessdelivery workflow;
- не предназначен для установки поверх давно используемого сервера с неизвестными firewall/systemd‑правками. - не предназначен для установки поверх давно используемого сервера с неизвестными firewall/systemd‑правками.
@@ -147,10 +151,74 @@ hy2xs-install/
| TrafficStats Hysteria2 | `127.0.0.1:36712` | | TrafficStats Hysteria2 | `127.0.0.1:36712` |
| Firewall mode | `takeover` в packaged baseline | | Firewall mode | `takeover` в packaged baseline |
| Hysteria2 auth | `http` через локальный HY2XS admin | | 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`. Важно: `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 сервер. Ниже приведён полный путь для оператора, который работает с Windows и ставит HY2XS на чистый Debian 13 сервер.
@@ -379,10 +447,12 @@ HY2XS_IPV6_ENABLED=false
HY2XS_TLS_MODE=acme HY2XS_TLS_MODE=acme
HY2XS_ACME_TYPE=http HY2XS_ACME_TYPE=http
HY2XS_HYSTERIA_AUTH_MODE=http HY2XS_HYSTERIA_AUTH_MODE=http
HY2XS_HYSTERIA_OBFS_TYPE=salamander HY2XS_HYSTERIA_OBFS_TYPE=gecko
HY2XS_UI_PUBLIC_ACCESS=false HY2XS_UI_PUBLIC_ACCESS=false
``` ```
Если нужен режим совместимости со старыми клиентами, укажите `HY2XS_HYSTERIA_OBFS_TYPE=salamander`. Подробнее — в разделе [Обфускация](#обфускация).
### 9. Запустите установку ### 9. Запустите установку
```bash ```bash
@@ -396,7 +466,7 @@ HY2XS_UI_PUBLIC_ACCESS=false
3. создаст runtime‑каталоги и service users; 3. создаст runtime‑каталоги и service users;
4. запишет `/etc/hy2xs/hy2xs.env`; 4. запишет `/etc/hy2xs/hy2xs.env`;
5. разложит bundled HY2XS admin; 5. разложит bundled HY2XS admin;
6. скачает pinned Hysteria2 binary из upstream и проверит SHA256; 6. скачает закреплённый в пакете Hysteria2 binary из upstream, проверит SHA256 и фактическую версию;
7. создаст `/etc/hysteria/config.yaml`; 7. создаст `/etc/hysteria/config.yaml`;
8. установит systemd‑юниты; 8. установит systemd‑юниты;
9. применит nftables‑правила; 9. применит nftables‑правила;
@@ -507,6 +577,7 @@ hy2xs-orchestrator status \
| Переменная | Назначение | Значение по умолчанию в packaged baseline | | Переменная | Назначение | Значение по умолчанию в packaged baseline |
| --- | --- | --- | | --- | --- | --- |
| `HY2XS_CONFIG_SCHEMA_VERSION` | Версия схемы конфигурации HY2XS. Конфигурация другой схемы отклоняется fail‑fast | `2` |
| `HY2XS_IPV6_ENABLED` | IPv6‑режим. В production baseline должен быть `false` | `false` | | `HY2XS_IPV6_ENABLED` | IPv6‑режим. В production baseline должен быть `false` | `false` |
| `HY2XS_DOMAIN` | Домен для ACME и deploy‑профиля | `fi.api.withen.pro` | | `HY2XS_DOMAIN` | Домен для ACME и deploy‑профиля | `fi.api.withen.pro` |
| `HY2XS_DNS_AAAA_POLICY` | Поведение при наличии AAAA‑записи: `strict`, `warn`, `off` | `strict` | | `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_HOST` | Host trafficStats API | `127.0.0.1` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT` | Порт trafficStats API | `36712` | | `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT` | Порт trafficStats API | `36712` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` | | `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` |
| `HY2XS_HYSTERIA_OBFS_TYPE` | Obfuscation type. Фиксированное значение production‑профиля | `salamander` | | `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` |
| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Salamander password | `__GENERATE__` | | `HY2XS_HYSTERIA_OBFS_PASSWORD` | Пароль обфускации; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_HYSTERIA_BANDWIDTH_UP` | Hysteria2 upstream bandwidth | `50 mbps` | | `HY2XS_HYSTERIA_BANDWIDTH_UP` | Hysteria2 upstream bandwidth | `50 mbps` |
| `HY2XS_HYSTERIA_BANDWIDTH_DOWN` | Hysteria2 downstream bandwidth | `50 mbps` | | `HY2XS_HYSTERIA_BANDWIDTH_DOWN` | Hysteria2 downstream bandwidth | `50 mbps` |
| `HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH` | Игнорировать bandwidth клиента | `false` | | `HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH` | Игнорировать bandwidth клиента | `false` |
@@ -717,7 +788,7 @@ git status --short
Подготовьте build env: Подготовьте build env:
```bash ```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) export BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ)
# Для переносимости между x86_64-серверами без AVX2 предпочтителен baseline artifact. # Для переносимости между x86_64-серверами без AVX2 предпочтителен baseline artifact.
@@ -728,6 +799,9 @@ export BUN_FLAVOR=x64-baseline
export GO_ARCHIVE_SHA256=<sha256-go1.21.13-linux-amd64.tar.gz> export GO_ARCHIVE_SHA256=<sha256-go1.21.13-linux-amd64.tar.gz>
export NODE_ARCHIVE_SHA256=<sha256-node-v20.19.0-linux-x64.tar.xz> export NODE_ARCHIVE_SHA256=<sha256-node-v20.19.0-linux-x64.tar.xz>
export BUN_ARCHIVE_SHA256=<sha256-bun-linux-x64-baseline-1.3.13.zip> export BUN_ARCHIVE_SHA256=<sha256-bun-linux-x64-baseline-1.3.13.zip>
# Опционально: снимает anonymous rate limit при разрешении upstream-релиза.
export GITHUB_TOKEN=<token>
``` ```
Запустите сборку: Запустите сборку:
@@ -736,6 +810,32 @@ export BUN_ARCHIVE_SHA256=<sha256-bun-linux-x64-baseline-1.3.13.zip>
./tools/build/build.sh ./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 ```text
@@ -748,7 +848,7 @@ dist/hy2xs-install-<version>.tar.gz
ls -lh dist/hy2xs-install-*.tar.gz ls -lh dist/hy2xs-install-*.tar.gz
sha256sum 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)$' '^(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 ├── orchestrator/ # install-only orchestrator на Bun + TypeScript
├── package/ # skeleton будущего install package ├── package/ # skeleton будущего install package
├── tools/build/ # production builder и packaging pipeline ├── tools/build/ # production builder и packaging pipeline
├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria
├── docs/ # спецификации baseline, тестов и эксплуатации
├── CHANGELOG.md
├── README.md ├── README.md
└── LICENSE └── LICENSE
``` ```
+7 -58
View File
@@ -4,9 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
@@ -183,72 +180,24 @@ func UpdateHysteria2Config(c *gin.Context) {
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c) vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
} }
// ExportHysteria2Config отдаёт оператору фактический серверный конфиг.
//
// Экспорт работает от исходного YAML, а не от типизированной модели: поля,
// о которых HY2XS ещё не знает, обязаны пережить выгрузку. Секреты при этом
// вырезаются — файл покидает сервер.
func ExportHysteria2Config(c *gin.Context) { func ExportHysteria2Config(c *gin.Context) {
hysteria2ServerConfig, err := service.GetHysteria2Config() sanitized, err := service.ExportHysteria2ConfigYaml()
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return 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")) 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-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary") c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) 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) { func ImportHysteria2Config(c *gin.Context) {
+54 -9
View File
@@ -26,6 +26,10 @@ export interface Hysteria2ServerConfig {
cert: string; cert: string;
key: string; key: string;
sniGuard?: string; sniGuard?: string;
clientCA?: string;
};
ech?: {
keyPath?: string;
}; };
acme?: { acme?: {
domains: string[]; domains: string[];
@@ -51,9 +55,14 @@ export interface Hysteria2ServerConfig {
}; };
obfs?: { obfs?: {
type: string; type: string;
salamander: { salamander?: {
password: string; password: string;
}; };
gecko?: {
password: string;
minPacketSize?: number;
maxPacketSize?: number;
};
}; };
quic?: { quic?: {
initStreamReceiveWindow?: number; initStreamReceiveWindow?: number;
@@ -63,10 +72,16 @@ export interface Hysteria2ServerConfig {
maxIdleTimeout?: string; maxIdleTimeout?: string;
maxIncomingStreams?: number; maxIncomingStreams?: number;
disablePathMTUDiscovery?: boolean; disablePathMTUDiscovery?: boolean;
disableStatelessReset?: boolean;
}; };
bandwidth?: { bandwidth?: {
up: string; up: string;
down: string; down: string;
disableLossCompensation?: boolean;
};
congestion?: {
type?: string;
bbrProfile?: string;
}; };
ignoreClientBandwidth?: boolean; ignoreClientBandwidth?: boolean;
speedTest?: boolean; speedTest?: boolean;
@@ -122,6 +137,7 @@ export interface Hysteria2ServerConfig {
url: string; url: string;
rewriteHost: boolean; rewriteHost: boolean;
insecure: boolean; insecure: boolean;
xForwarded?: boolean;
}; };
string?: { string?: {
content: string; content: string;
@@ -132,6 +148,26 @@ export interface Hysteria2ServerConfig {
listenHTTPS?: string; listenHTTPS?: string;
forceHTTPS?: boolean; 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 = { export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
@@ -144,9 +180,9 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
acme: { acme: {
domains: [], domains: [],
email: "", email: "",
ca: "zerossl", ca: "letsencrypt",
listenHost: "0.0.0.0", listenHost: "0.0.0.0",
dir: "my_acme_dir", dir: "/var/lib/hysteria/acme",
type: "", type: "",
http: { http: {
altPort: 8888, altPort: 8888,
@@ -155,7 +191,7 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
altPort: 44333, altPort: 44333,
}, },
dns: { dns: {
name: "gomommy", name: "cloudflare",
config: {}, config: {},
}, },
disableHTTP: false, disableHTTP: false,
@@ -164,9 +200,11 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
altTLSALPNPort: 443, altTLSALPNPort: 443,
}, },
obfs: { obfs: {
type: "salamander", type: "gecko",
salamander: { gecko: {
password: "cry_me_a_r1ver", password: "",
minPacketSize: 512,
maxPacketSize: 1200,
}, },
}, },
quic: { quic: {
@@ -177,10 +215,16 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
maxIdleTimeout: "30s", maxIdleTimeout: "30s",
maxIncomingStreams: 1024, maxIncomingStreams: 1024,
disablePathMTUDiscovery: false, disablePathMTUDiscovery: false,
disableStatelessReset: false,
}, },
bandwidth: { bandwidth: {
up: "1 gbps", up: "50 mbps",
down: "1 gbps", down: "50 mbps",
disableLossCompensation: false,
},
congestion: {
type: "bbr",
bbrProfile: "standard",
}, },
ignoreClientBandwidth: false, ignoreClientBandwidth: false,
speedTest: false, speedTest: false,
@@ -236,6 +280,7 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
url: "", url: "",
rewriteHost: true, rewriteHost: true,
insecure: false, insecure: false,
xForwarded: false,
}, },
string: { string: {
content: "hello stupid world", content: "hello stupid world",
+28 -1
View File
@@ -128,6 +128,17 @@ export default {
name: "Peer", name: "Peer",
remark: "Remark", remark: "Remark",
secret: "Secret", 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", maxDevices: "Max devices",
disabled: "Disabled", disabled: "Disabled",
status: "Status", status: "Status",
@@ -234,6 +245,7 @@ export default {
obfs: "Obfuscation", obfs: "Obfuscation",
quic: "QUIC parameters", quic: "QUIC parameters",
bandwidth: "Bandwidth", bandwidth: "Bandwidth",
congestion: "Congestion control",
speedTest: "Speed Test", speedTest: "Speed Test",
udp: "UDP", udp: "UDP",
resolver: "Resolver", 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.)", "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: { obfs: {
type: "Type", type: "Obfuscation type: gecko (HY2XS production default) or salamander (compatibility fallback).",
salamander: { salamander: {
password: "Replace with a strong password of your choice.", 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: { quic: {
initStreamReceiveWindow: "The initial QUIC stream receive window size.", initStreamReceiveWindow: "The initial QUIC stream receive window size.",
@@ -299,10 +318,18 @@ export default {
maxIncomingStreams: maxIncomingStreams:
"The maximum number of concurrent incoming streams.", "The maximum number of concurrent incoming streams.",
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.", 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: { bandwidth: {
up: "Up", up: "Up",
down: "Down", 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: ignoreClientBandwidth:
"When enabled, makes the server to disregard any bandwidth hints set by clients", "When enabled, makes the server to disregard any bandwidth hints set by clients",
+32 -2
View File
@@ -124,6 +124,17 @@ export default {
name: "Пир", name: "Пир",
remark: "Комментарий", remark: "Комментарий",
secret: "Секрет", secret: "Секрет",
form: {
namePlaceholder: "например, ivan-laptop",
nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет.",
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
secretHint:
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
},
maxDevices: "Лимит устройств", maxDevices: "Лимит устройств",
disabled: "Отключён", disabled: "Отключён",
status: "Статус", status: "Статус",
@@ -230,6 +241,7 @@ export default {
obfs: "Маскировка", obfs: "Маскировка",
quic: "Параметры QUIC", quic: "Параметры QUIC",
bandwidth: "Полоса", bandwidth: "Полоса",
congestion: "Congestion control",
speedTest: "Тест скорости", speedTest: "Тест скорости",
udp: "UDP", udp: "UDP",
resolver: "DNS", resolver: "DNS",
@@ -264,8 +276,15 @@ export default {
altTLSALPNPort: "Альтернативный TLS-ALPN-порт", altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
}, },
obfs: { obfs: {
type: "Тип", type: "Тип обфускации: gecko (production default HY2XS) или salamander (совместимость)",
salamander: { password: "Сильный пароль Salamander" }, salamander: { password: "Сильный пароль Salamander" },
gecko: {
password: "Сильный пароль Gecko",
minPacketSize:
"Минимальный размер фрагмента QUIC handshake. Baseline HY2XS: 512",
maxPacketSize:
"Максимальный размер фрагмента QUIC handshake. Baseline HY2XS: 1200, верхний предел upstream: 2048",
},
}, },
quic: { quic: {
initStreamReceiveWindow: "Начальное окно приёма QUIC stream", initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
@@ -275,8 +294,19 @@ export default {
maxIdleTimeout: "Максимальный idle timeout", maxIdleTimeout: "Максимальный idle timeout",
maxIncomingStreams: "Максимум входящих stream", maxIncomingStreams: "Максимум входящих stream",
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery", 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, заявленный клиентом", ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
speedTest: "Встроенный сервер теста скорости", speedTest: "Встроенный сервер теста скорости",
disableUDP: "Отключить UDP forwarding", disableUDP: "Отключить UDP forwarding",
+162 -15
View File
@@ -361,21 +361,69 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-tooltip> </el-tooltip>
<el-tooltip <template
v-if="dataForm.obfs.type === 'salamander'" v-if="
:content="$t('hysteria.config.obfs.salamander.password')" dataForm.obfs.type === 'salamander' && dataForm.obfs.salamander
placement="bottom" "
> >
<el-form-item <el-tooltip
label="obfs.salamander.password" :content="$t('hysteria.config.obfs.salamander.password')"
prop="obfs.salamander.password" placement="bottom"
> >
<el-input <el-form-item
v-model="dataForm.obfs.salamander.password" label="obfs.salamander.password"
clearable prop="obfs.salamander.password"
/> >
</el-form-item> <el-input
</el-tooltip> v-model="dataForm.obfs.salamander.password"
clearable
/>
</el-form-item>
</el-tooltip>
</template>
<template
v-if="dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko"
>
<el-tooltip
:content="$t('hysteria.config.obfs.gecko.password')"
placement="bottom"
>
<el-form-item
label="obfs.gecko.password"
prop="obfs.gecko.password"
>
<el-input v-model="dataForm.obfs.gecko.password" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.obfs.gecko.minPacketSize')"
placement="bottom"
>
<el-form-item
label="obfs.gecko.minPacketSize"
prop="obfs.gecko.minPacketSize"
>
<el-input
v-model.number="dataForm.obfs.gecko.minPacketSize"
clearable
/>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.obfs.gecko.maxPacketSize')"
placement="bottom"
>
<el-form-item
label="obfs.gecko.maxPacketSize"
prop="obfs.gecko.maxPacketSize"
>
<el-input
v-model.number="dataForm.obfs.gecko.maxPacketSize"
clearable
/>
</el-form-item>
</el-tooltip>
</template>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="$t('hysteria.quic')" name="quic" v-if="quic"> <el-tab-pane :label="$t('hysteria.quic')" name="quic" v-if="quic">
<el-tooltip <el-tooltip
@@ -470,6 +518,17 @@
<el-switch v-model="dataForm.quic.disablePathMTUDiscovery" /> <el-switch v-model="dataForm.quic.disablePathMTUDiscovery" />
</el-form-item> </el-form-item>
</el-tooltip> </el-tooltip>
<el-tooltip
:content="$t('hysteria.config.quic.disableStatelessReset')"
placement="bottom"
>
<el-form-item
label="quic.disableStatelessReset"
prop="quic.disableStatelessReset"
>
<el-switch v-model="dataForm.quic.disableStatelessReset" />
</el-form-item>
</el-tooltip>
</el-tab-pane> </el-tab-pane>
<el-tab-pane <el-tab-pane
:label="$t('hysteria.bandwidth')" :label="$t('hysteria.bandwidth')"
@@ -494,6 +553,19 @@
<el-input v-model="dataForm.bandwidth.down" clearable /> <el-input v-model="dataForm.bandwidth.down" clearable />
</el-form-item> </el-form-item>
</el-tooltip> </el-tooltip>
<el-tooltip
:content="$t('hysteria.config.bandwidth.disableLossCompensation')"
placement="bottom"
>
<el-form-item
label="bandwidth.disableLossCompensation"
prop="bandwidth.disableLossCompensation"
>
<el-switch
v-model="dataForm.bandwidth.disableLossCompensation"
/>
</el-form-item>
</el-tooltip>
<el-tooltip <el-tooltip
:content="$t('hysteria.config.ignoreClientBandwidth')" :content="$t('hysteria.config.ignoreClientBandwidth')"
placement="bottom" placement="bottom"
@@ -506,6 +578,53 @@
</el-form-item> </el-form-item>
</el-tooltip> </el-tooltip>
</el-tab-pane> </el-tab-pane>
<el-tab-pane
:label="$t('hysteria.congestion')"
name="congestion"
v-if="congestion && dataForm.congestion"
>
<el-tooltip
:content="$t('hysteria.config.congestion.type')"
placement="bottom"
>
<el-form-item label="congestion.type" prop="congestion.type">
<el-select
v-model="dataForm.congestion.type"
style="width: 100%"
clearable
>
<el-option
v-for="item in congestionTypes"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.congestion.bbrProfile')"
placement="bottom"
>
<el-form-item
label="congestion.bbrProfile"
prop="congestion.bbrProfile"
>
<el-select
v-model="dataForm.congestion.bbrProfile"
style="width: 100%"
clearable
>
<el-option
v-for="item in bbrProfiles"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-tooltip>
</el-tab-pane>
<el-tab-pane <el-tab-pane
:label="$t('hysteria.speedTest')" :label="$t('hysteria.speedTest')"
name="speedTest" name="speedTest"
@@ -1020,15 +1139,22 @@ const tlsSniGuards = ref<string[]>(["dns-san", "disable", "strict"]);
const aclTypes = ref<string[]>(["file", "inline"]); const aclTypes = ref<string[]>(["file", "inline"]);
const acmeCas = ref<string[]>(["zerossl", "letsencrypt"]); const acmeCas = ref<string[]>(["zerossl", "letsencrypt"]);
const acmeTypes = ref<string[]>(["http", "tls", "dns"]); const acmeTypes = ref<string[]>(["http", "tls", "dns"]);
// Актуальный upstream-реестр ACME DNS-провайдеров.
// `namedotcom` удалён в Hysteria 2.11.0 вместе с переписанным ACME-стеком:
// конфигурация с ним больше не запускается.
const dnsNames = ref<string[]>([ const dnsNames = ref<string[]>([
"cloudflare", "cloudflare",
"duckdns", "duckdns",
"gandi", "gandi",
"godaddy", "godaddy",
"namedotcom", "namecheap",
"njalla",
"porkbun",
"vultr", "vultr",
]); ]);
const obfsTypes = ref<string[]>(["salamander"]); const obfsTypes = ref<string[]>(["gecko", "salamander"]);
const congestionTypes = ref<string[]>(["bbr", "reno"]);
const bbrProfiles = ref<string[]>(["standard", "conservative", "aggressive"]);
const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]); const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]);
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]); const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
@@ -1043,6 +1169,7 @@ const state = reactive({
obfs: false, obfs: false,
quic: false, quic: false,
bandwidth: false, bandwidth: false,
congestion: false,
speedTest: false, speedTest: false,
udp: false, udp: false,
resolver: false, resolver: false,
@@ -1067,6 +1194,7 @@ const {
obfs, obfs,
quic, quic,
bandwidth, bandwidth,
congestion,
speedTest, speedTest,
udp, udp,
resolver, resolver,
@@ -1120,6 +1248,7 @@ const setConfig = () => {
state.obfs = !!data?.obfs; state.obfs = !!data?.obfs;
state.quic = !!data?.quic; state.quic = !!data?.quic;
state.bandwidth = !!data?.bandwidth; state.bandwidth = !!data?.bandwidth;
state.congestion = !!data?.congestion;
state.speedTest = !!data?.speedTest; state.speedTest = !!data?.speedTest;
state.udp = !!data?.disableUDP || !!data?.udpIdleTimeout; state.udp = !!data?.disableUDP || !!data?.udpIdleTimeout;
state.resolver = !!data?.resolver; state.resolver = !!data?.resolver;
@@ -1130,10 +1259,28 @@ const setConfig = () => {
state.dataForm = deepCopy(defaultHysteria2ServerConfig); state.dataForm = deepCopy(defaultHysteria2ServerConfig);
assignWith(state.dataForm, data); 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 setHysteria2Monitor = async () => {
const { data } = await dashboardSummaryApi(); const { data } = await dashboardSummaryApi();
state.hysteria2Monitor.version = data.hysteria.version; state.hysteria2Monitor.version = data.hysteria.version;
+39 -15
View File
@@ -65,9 +65,7 @@
<el-table-column :label="$t('peer.traffic')" min-width="260"> <el-table-column :label="$t('peer.traffic')" min-width="260">
<template #default="scope"> <template #default="scope">
<div> <div>
{{ {{ formatBytes(scope.row.downloadBytes + scope.row.uploadBytes) }}
formatBytes(scope.row.downloadBytes + scope.row.uploadBytes)
}}
/ {{ quotaText(scope.row.quotaBytes) }} / {{ quotaText(scope.row.quotaBytes) }}
</div> </div>
<el-progress <el-progress
@@ -167,18 +165,32 @@
:rules="rules" :rules="rules"
label-width="140px" label-width="140px"
> >
<el-form-item :label="$t('peer.name')" prop="name" <el-form-item :label="$t('peer.name')" prop="name">
><el-input v-model="dataForm.name" <el-input
/></el-form-item> v-model="dataForm.name"
<el-form-item :label="$t('peer.remark')" :placeholder="$t('peer.form.namePlaceholder')"
><el-input v-model="dataForm.remark" />
/></el-form-item> <div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
<el-form-item :label="$t('peer.secret')" prop="secret" </el-form-item>
><el-input v-model="dataForm.secret" show-password <el-form-item :label="$t('peer.remark')">
/></el-form-item> <el-input
<el-form-item :label="$t('peer.quota')" v-model="dataForm.remark"
><el-input-number v-model="dataForm.quotaBytes" :min="-1" :placeholder="$t('peer.form.remarkPlaceholder')"
/></el-form-item> />
<div class="form-hint">{{ $t("peer.form.remarkHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.secret')" prop="secret">
<el-input
v-model="dataForm.secret"
show-password
:placeholder="$t('peer.form.secretPlaceholder')"
/>
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.quota')">
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.expireTime')" <el-form-item :label="$t('peer.expireTime')"
><el-date-picker ><el-date-picker
v-model="dataForm.expiresAt" v-model="dataForm.expiresAt"
@@ -573,23 +585,35 @@ onMounted(handleQuery);
.peer-title { .peer-title {
font-weight: 600; font-weight: 600;
} }
.peer-sub { .peer-sub {
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
} }
.peer-actions { .peer-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
} }
.peer-more-btn { .peer-more-btn {
font-size: 16px; font-size: 16px;
} }
.qr-dialog-body { .qr-dialog-body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 12px 0 20px; padding: 12px 0 20px;
} }
.form-hint {
width: 100%;
margin-top: 2px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
</style> </style>
+114 -1
View File
@@ -1,12 +1,55 @@
package bo 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 { type Hysteria2ServerConfig struct {
Listen *string `yaml:"listen,omitempty" json:"listen" validate:"required"` Listen *string `yaml:"listen,omitempty" json:"listen" validate:"required"`
Obfs *serverConfigObfs `yaml:"obfs,omitempty" json:"obfs" validate:"omitempty"` Obfs *serverConfigObfs `yaml:"obfs,omitempty" json:"obfs" validate:"omitempty"`
TLS *serverConfigTLS `yaml:"tls,omitempty" json:"tls" validate:"omitempty"` TLS *serverConfigTLS `yaml:"tls,omitempty" json:"tls" validate:"omitempty"`
ACME *serverConfigACME `yaml:"acme,omitempty" json:"acme" 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"` QUIC *serverConfigQUIC `yaml:"quic,omitempty" json:"quic" validate:"omitempty"`
Bandwidth *serverConfigBandwidth `yaml:"bandwidth,omitempty" json:"bandwidth" 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"` IgnoreClientBandwidth *bool `yaml:"ignoreClientBandwidth,omitempty" json:"ignoreClientBandwidth" validate:"omitempty"`
SpeedTest *bool `yaml:"speedTest,omitempty" json:"speedTest" validate:"omitempty"` SpeedTest *bool `yaml:"speedTest,omitempty" json:"speedTest" validate:"omitempty"`
DisableUDP *bool `yaml:"disableUDP,omitempty" json:"disableUDP" 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"` Outbounds []serverConfigOutboundEntry `yaml:"outbounds,omitempty" json:"outbounds" validate:"omitempty"`
TrafficStats *ServerConfigTrafficStats `yaml:"trafficStats,omitempty" json:"trafficStats" validate:"required"` TrafficStats *ServerConfigTrafficStats `yaml:"trafficStats,omitempty" json:"trafficStats" validate:"required"`
Masquerade *serverConfigMasquerade `yaml:"masquerade,omitempty" json:"masquerade" validate:"omitempty"` 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 { type serverConfigObfsSalamander struct {
Password *string `yaml:"password,omitempty" json:"password" validate:"required"` 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 serverConfigObfs struct {
Type *string `yaml:"type,omitempty" json:"type" validate:"required"` 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 { type serverConfigTLS struct {
Cert *string `yaml:"cert,omitempty" json:"cert" validate:"required"` Cert *string `yaml:"cert,omitempty" json:"cert" validate:"required"`
Key *string `yaml:"key,omitempty" json:"key" validate:"required"` Key *string `yaml:"key,omitempty" json:"key" validate:"required"`
SNIGuard *string `yaml:"sniGuard,omitempty" json:"sniGuard" validate:"omitempty"` 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 { type serverConfigACME struct {
@@ -78,11 +143,25 @@ type serverConfigQUIC struct {
MaxIdleTimeout *string `yaml:"maxIdleTimeout,omitempty" json:"maxIdleTimeout" validate:"omitempty"` MaxIdleTimeout *string `yaml:"maxIdleTimeout,omitempty" json:"maxIdleTimeout" validate:"omitempty"`
MaxIncomingStreams *int64 `yaml:"maxIncomingStreams,omitempty" json:"maxIncomingStreams" validate:"omitempty"` MaxIncomingStreams *int64 `yaml:"maxIncomingStreams,omitempty" json:"maxIncomingStreams" validate:"omitempty"`
DisablePathMTUDiscovery *bool `yaml:"disablePathMTUDiscovery,omitempty" json:"disablePathMTUDiscovery" 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 { type serverConfigBandwidth struct {
Up *string `yaml:"up,omitempty" json:"up" validate:"required"` Up *string `yaml:"up,omitempty" json:"up" validate:"required"`
Down *string `yaml:"down,omitempty" json:"down" 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 { type ServerConfigAuthHTTP struct {
@@ -183,9 +262,11 @@ type serverConfigMasqueradeFile struct {
} }
type serverConfigMasqueradeProxy struct { type serverConfigMasqueradeProxy struct {
// URL с Hysteria 2.12.2 может указывать и на unix socket.
URL *string `yaml:"url,omitempty" json:"url" validate:"required"` URL *string `yaml:"url,omitempty" json:"url" validate:"required"`
RewriteHost *bool `yaml:"rewriteHost,omitempty" json:"rewriteHost" validate:"required"` RewriteHost *bool `yaml:"rewriteHost,omitempty" json:"rewriteHost" validate:"required"`
Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"` Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"`
XForwarded *bool `yaml:"xForwarded,omitempty" json:"xForwarded" validate:"omitempty"`
} }
type serverConfigMasqueradeString struct { type serverConfigMasqueradeString struct {
@@ -203,3 +284,35 @@ type serverConfigMasquerade struct {
ListenHTTPS *string `yaml:"listenHTTPS,omitempty" json:"listenHTTPS" validate:"omitempty"` ListenHTTPS *string `yaml:"listenHTTPS,omitempty" json:"listenHTTPS" validate:"omitempty"`
ForceHTTPS *bool `yaml:"forceHTTPS,omitempty" json:"forceHTTPS" 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"`
}
+36 -19
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/proxy" "hy2xs-admin/proxy"
"net" "net"
@@ -14,6 +15,28 @@ import (
"time" "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) { func resolvePublicEndpoint() (string, int, error) {
host := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_HOST")) host := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_HOST"))
if host == "" || host == "0.0.0.0" { if host == "" || host == "0.0.0.0" {
@@ -135,20 +158,8 @@ func Hysteria2Url(accountId int64) (string, error) {
remark = *hysteria2ConfigRemark.Value remark = *hysteria2ConfigRemark.Value
} }
obfsType := "" obfs := hysteria2Config.ObfsShare()
obfsPassword := "" sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
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]
}
secret := "" secret := ""
if peer.SecretEncrypted != nil { if peer.SecretEncrypted != nil {
@@ -158,14 +169,20 @@ func Hysteria2Url(accountId int64) (string, error) {
} }
secret = decrypted 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{} query := url.Values{}
if obfsType == "salamander" && obfsPassword != "" { if isShareableObfsType(obfs.Type) && obfs.Password != "" {
query.Set("obfs", "salamander") query.Set("obfs", obfs.Type)
query.Set("obfs-password", obfsPassword) query.Set("obfs-password", obfs.Password)
} }
if sni != "" { if sni != "" {
query.Set("sni", sni) query.Set("sni", sni)
+276 -29
View File
@@ -4,26 +4,115 @@ import (
"net/url" "net/url"
"strings" "strings"
"testing" "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) { func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
raw := buildHysteria2Url( raw := buildHysteria2Url(
"u@ser:#&=+ pass", "u@ser:#&=+ pass",
"example.com", "example.com",
443, 443,
"salamander", bo.ObfsShareConfig{Type: "gecko", Password: "obf+s&pass=@x"},
"obf+s&pass=@x",
"exa mple.com", "exa mple.com",
"my remark #1", "my remark #1",
) )
parsed, err := url.Parse(raw) parsed := mustParse(t, 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)
}
if parsed.User == nil { if parsed.User == nil {
t.Fatal("expected userinfo to be present") t.Fatal("expected userinfo to be present")
} }
@@ -32,42 +121,200 @@ func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
} }
q := parsed.Query() 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" { if q.Get("obfs-password") != "obf+s&pass=@x" {
t.Fatalf("expected decoded obfs-password, got %q", q.Get("obfs-password")) t.Fatalf("expected decoded obfs-password, got %q", q.Get("obfs-password"))
} }
if q.Get("sni") != "exa mple.com" { if q.Get("sni") != "exa mple.com" {
t.Fatalf("expected decoded sni, got %q", q.Get("sni")) 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" { if parsed.Fragment != "my remark #1" {
t.Fatalf("expected decoded fragment, got %q", parsed.Fragment) t.Fatalf("expected decoded fragment, got %q", parsed.Fragment)
} }
if strings.Contains(raw, "u@ser:#&=+ pass") { if strings.Contains(raw, "u@ser:#&=+ pass") {
t.Fatalf("raw uri must not contain unescaped userinfo: %s", raw) t.Fatalf("raw uri must not contain unescaped userinfo: %s", raw)
} }
} }
func TestBuildHysteria2Url_MinimalConfig(t *testing.T) { // Регрессия на upstream-баг 2.9.3: `+` в credentials при разборе share link
raw := buildHysteria2Url("pass", "example.com", 8443, "", "", "", "") // превращался в пробел. Проверяем, что кодирование однозначно.
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) for _, value := range cases {
if err != nil { raw := buildHysteria2Url(value, "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: value}, "", "")
t.Fatalf("expected valid url, got error: %v", err) 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" { space := buildHysteria2Url("a b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a b"}, "", "")
t.Fatalf("expected insecure=0, got %q", q.Get("insecure")) if mustParse(t, space).Query().Get("obfs-password") == "a+b" {
} t.Fatalf("space and '+' must not collapse to the same value: %s", space)
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" { }
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery) }
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")
} }
} }
+208
View File
@@ -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 = "<redacted>"
// 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()
}
+247
View File
@@ -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)
}
}
+3 -2
View File
@@ -41,9 +41,10 @@ Target layer **не содержит сборщика** и **не выполня
- **Hysteria2** - **Hysteria2**
- QUIC/UDP - QUIC/UDP
- один фиксированный UDP-порт - один фиксированный UDP-порт
- `Salamander` включён по умолчанию - `Gecko` включён по умолчанию, `Salamander` доступен как режим совместимости
- IPv4-only - IPv4-only
- лимит по умолчанию: 50/50 Mbps на клиента - лимит по умолчанию: 50/50 Mbps на клиента
- fallback congestion controller: BBR (профиль `standard`)
### UI слой ### UI слой
- **HY2XS admin** — штатный компонент проекта - **HY2XS admin** — штатный компонент проекта
@@ -65,7 +66,7 @@ Target layer **не содержит сборщика** и **не выполня
## Принципы ## Принципы
### 1. Ядро, UI и оркестратор ведут себя по-разному ### 1. Ядро, UI и оркестратор ведут себя по-разному
- Hysteria2: берём свежую upstream-версию при установке - Hysteria2: последнюю стабильную upstream-версию выбирает **сборка пакета**, установка ставит уже замороженный артефакт
- HY2XS admin: разрабатываем **внутри проекта** и поставляем его сами - HY2XS admin: разрабатываем **внутри проекта** и поставляем его сами
- Оркестратор: пишем на **Bun + TypeScript**, но собираем **локально**, а не на target - Оркестратор: пишем на **Bun + TypeScript**, но собираем **локально**, а не на target
+81 -13
View File
@@ -43,13 +43,17 @@ Builder не является частью target install flow: на target serv
## Что делает builder ## Что делает builder
1. Проверяет структуру проекта. 1. Проверяет структуру проекта.
2. Собирает / подготавливает HY2XS admin. 2. Прогоняет тесты и типы оркестратора.
3. Компилирует оркестратор из Bun/TypeScript в install-артефакт. 3. Разрешает upstream-версию Hysteria и проходит compatibility gate.
4. Копирует артефакты UI в package staging directory. 4. Компилирует оркестратор из Bun/TypeScript в install-артефакт.
5. Кладёт entrypoint, templates, docs и service files. 5. Собирает / подготавливает HY2XS admin.
6. Формирует итоговый install package. 6. Прогоняет тесты HY2XS admin (после сборки frontend: `go:embed all:dist` требует готовых ассетов).
7. При необходимости считает manifest/checksum. 7. Копирует артефакты UI в package staging directory.
8. Выдаёт один переносимый результат для target machine. 8. Кладёт entrypoint, templates, docs и service files.
9. Формирует итоговый install package.
10. Считает manifest/checksum.
11. Проверяет архив и прогоняет acceptance-проверки.
12. Выдаёт один переносимый результат для target machine.
## Что builder не делает ## Что builder не делает
@@ -119,14 +123,76 @@ project/
Сам бинарь Hysteria2 **не вендорится** в install package как baseline-правило. Сам бинарь Hysteria2 **не вендорится** в install package как baseline-правило.
Причина: Причина:
- ядро Hysteria рассматривается как stable upstream component - ядро Hysteria рассматривается как stable upstream component;
- целевая установка должна брать его с official upstream на момент развёртывания - целевая установка скачивает его с 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 = <immutable release asset>
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.version`, `metadata/hysteria.url`, `metadata/hysteria.sha256`);
- URL артефакта и SHA256 фиксируются в metadata install package (`metadata/hysteria.url`, `metadata/hysteria.sha256`); - способ выбора версии фиксируется в `metadata/hysteria.resolution` и `metadata/package.env`;
- runtime `reconfigure` не обновляет и не откатывает бинарник Hysteria2; - 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 3. на сервере нет отдельного build step
4. bundled UI уже находится внутри пакета 4. bundled UI уже находится внутри пакета
5. оркестратор authored as Bun/TypeScript, но на target приходит как готовый install-артефакт 5. оркестратор authored as Bun/TypeScript, но на target приходит как готовый install-артефакт
6. Hysteria2 подтягивается install layer'ом с upstream, а не собирается на target из исходников 6. Hysteria2 подтягивается install layer'ом с upstream по замороженным координатам, а не собирается на target из исходников
7. выход новой версии Hysteria после сборки не меняет содержимое уже собранного пакета
8. несовместимый upstream ломает сборку, а не установку у пользователя
+146 -18
View File
@@ -18,13 +18,53 @@ Hysteria2 — основной транспортный компонент се
## Версионная политика ## Версионная политика
С учётом выбранной модели «берём свежее из upstream» фиксируется такая практика: Ключевое правило: **«последняя стабильная» определяется на этапе сборки пакета, а не на целевом сервере.**
- по умолчанию install layer тянет **свежий upstream release / install source** Не «HY2XS использует Hysteria vX.Y.Z», а:
- фактически установленная версия обязательно записывается в `post-install.env`
- бинарник Hysteria2 устанавливается только на этапе `install` > HY2XS по умолчанию берёт последний стабильный релиз Hysteria, доступный на момент сборки пакета. Разрешённая версия, URL артефакта и контрольная сумма замораживаются в получившемся install package.
- версия, URL и SHA256 фиксируются в metadata install package
- `reconfigure` не обновляет и не откатывает бинарник Hysteria2 Практика:
- 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 включается: Новые установки HY2XS используют **Gecko**.
- `obfs.type: salamander`
- `obfs.password` 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: "<сгенерированный пароль>"
```
Правила: Правила:
- пароль должен быть сильным - тип выбирается через `HY2XS_HYSTERIA_OBFS_TYPE` (`gecko` | `salamander`);
- пароль должен фиксироваться в конфигурационном контуре - пароль должен быть сильным, генерируется автоматически при `__GENERATE__` или пустом значении;
- значение должно быть доступно оператору через runtime config и `post-install.env` - пароль фиксируется в конфигурационном контуре и доступен оператору через 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 ## TLS
@@ -72,8 +153,9 @@ Hysteria2 — основной транспортный компонент се
- `acme` block обязан содержать `type: http|tls` из runtime env (`HY2XS_ACME_TYPE`); - `acme` block обязан содержать `type: http|tls` из runtime env (`HY2XS_ACME_TYPE`);
- `HY2XS_ACME_TYPE=dns` в production-профиле запрещён до отдельной реализации; - `HY2XS_ACME_TYPE=dns` в production-профиле запрещён до отдельной реализации;
- `HY2XS_HYSTERIA_AUTH_MODE` зафиксирован в `http` и валидируется fail-fast; - `HY2XS_HYSTERIA_AUTH_MODE` зафиксирован в `http` и валидируется fail-fast;
- `HY2XS_HYSTERIA_OBFS_TYPE` зафиксирован в `salamander` и валидируется fail-fast; - `HY2XS_HYSTERIA_OBFS_TYPE` принимает `gecko` (default) или `salamander` и валидируется fail-fast;
- блок `masquerade` в baseline не задаётся (допустимо, но приводит к `404 Not Found` на обычный HTTP трафик); - блок `masquerade` в baseline не задаётся: при включённой обфускации сервер и так перестаёт быть обычным HTTP/3 endpoint, поэтому masquerade не даёт выигрыша, а `404 Not Found` на обычный HTTP-трафик — ожидаемое поведение;
- `ech` в baseline не включается: при включённой обфускации соединение целиком перестаёт выглядеть как обычный QUIC, поэтому ECH не даёт дополнительной выгоды (он полезен в bare-режиме);
- `file` -> только `tls.cert`/`tls.key` block; - `file` -> только `tls.cert`/`tls.key` block;
- `self_signed_dev` -> только dev сценарии. - `self_signed_dev` -> только dev сценарии.
@@ -91,11 +173,55 @@ Hysteria2 — основной транспортный компонент се
Серверная baseline policy: Серверная baseline policy:
- `bandwidth.up = 50 mbps` - `bandwidth.up = 50 mbps`
- `bandwidth.down = 50 mbps` - `bandwidth.down = 50 mbps`
- `bandwidth.disableLossCompensation = false`
- `ignoreClientBandwidth = 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 1. Hysteria2 получена из official upstream по замороженному в пакете URL и SHA-256
2. фактическая версия отражена в `post-install.env` 2. фактическая версия совпадает с версией из metadata пакета и отражена в `post-install.env`
3. конфиг валиден 3. конфиг валиден
4. сервис стартует через systemd 4. сервис стартует через systemd
5. нужный UDP-порт реально слушается 5. нужный UDP-порт реально слушается
@@ -118,3 +244,5 @@ Hysteria2 — основной транспортный компонент се
8. `trafficStats.secret` отдельный от `JWT_SECRET` 8. `trafficStats.secret` отдельный от `JWT_SECRET`
9. IPv6 listen не используется 9. IPv6 listen не используется
10. публичные клиентские endpoint/URL берутся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen`/request-host 10. публичные клиентские endpoint/URL берутся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen`/request-host
11. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации и пароль, и совместимый клиент подключается по ней напрямую
12. SNI в ссылке берётся из ACME-домена, затем из `HY2XS_DOMAIN`, затем из `HY2XS_PUBLIC_HOST`; IP-адрес как SNI не используется
+54 -1
View File
@@ -51,6 +51,54 @@ Target server **не собирает** admin-компонент из исход
HY2XS admin работает как надстройка над Hysteria YAML/API-слоем. Это нормально: важно только, чтобы источник истины по runtime-состоянию был понятен и не было двух конкурирующих конфигурационных миров без правил синхронизации. 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 ### Source of truth
@@ -81,7 +129,9 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP
- склеивать unit Hysteria2 и unit HY2XS admin в один сервис; - склеивать unit Hysteria2 и unit HY2XS admin в один сервис;
- раздувать оркестратор из-за особенностей панели; - раздувать оркестратор из-за особенностей панели;
- использовать HY2XS admin как updater бинаря Hysteria2; - использовать HY2XS admin как updater бинаря Hysteria2;
- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API. - использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API;
- экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
- выгружать конфиг с секретами в открытом виде.
## Что фиксировать в `post-install.env` ## Что фиксировать в `post-install.env`
@@ -107,3 +157,6 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP
5. Hysteria2 остаётся внешним vanilla upstream-компонентом 5. Hysteria2 остаётся внешним vanilla upstream-компонентом
6. HY2XS admin не выступает updater-менеджером Hysteria2 6. HY2XS admin не выступает updater-менеджером Hysteria2
7. `trafficStats.secret` не связан с `JWT_SECRET` 7. `trafficStats.secret` не связан с `JWT_SECRET`
8. экспорт конфига сохраняет неизвестные upstream-поля
9. экспорт конфига не содержит секретов
10. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую
+41 -4
View File
@@ -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 ## Что зафиксировано в baseline
### На сервере ### На сервере
- `bandwidth.up = 50 mbps` - `bandwidth.up = 50 mbps`
- `bandwidth.down = 50 mbps` - `bandwidth.down = 50 mbps`
- `bandwidth.disableLossCompensation = false`
- `ignoreClientBandwidth = false` - `ignoreClientBandwidth = false`
- `congestion.type = bbr`
- `congestion.bbrProfile = standard`
### На клиенте ### На клиенте
Совместимый клиентский конфиг должен задавать соответствующие bandwidth hints: Совместимый клиентский конфиг должен задавать соответствующие bandwidth hints:
@@ -29,13 +44,30 @@
Ожидаемый 50/50 Mbps contract считается корректным только тогда, когда сервер и клиентская конфигурация согласованы. Ожидаемый 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 ## Что делать с host-level BBR
`net.ipv4.tcp_congestion_control=bbr` можно оставить как общий системный тюнинг, но: `net.ipv4.tcp_congestion_control=bbr` можно оставить как общий системный тюнинг, но:
- это не главный механизм speed policy Hysteria2 - это не главный механизм speed policy Hysteria2;
- это не замена клиентским bandwidth hints - это не замена клиентским bandwidth hints;
- это не центр документации по лимитам - это **не то же самое**, что `congestion.type: bbr` в конфиге Hysteria — у Hysteria собственный congestion-control контур поверх QUIC;
- это не центр документации по лимитам.
## Что фиксировать в `post-install.env` ## Что фиксировать в `post-install.env`
@@ -43,8 +75,11 @@
- `HY2_BANDWIDTH_UP` - `HY2_BANDWIDTH_UP`
- `HY2_BANDWIDTH_DOWN` - `HY2_BANDWIDTH_DOWN`
- `HY2_IGNORE_CLIENT_BANDWIDTH` - `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» - «любой URI достаточно для полной speed policy»
- «host BBR и есть логика Hysteria» - «host BBR и есть логика Hysteria»
- «50 mbps — оптимальная скорость Hysteria» (это политика HY2XS, а не свойство протокола)
- «Brutal и congestion controller — одно и то же»
## Правильная baseline-формулировка ## Правильная baseline-формулировка
+10 -2
View File
@@ -59,6 +59,7 @@
- `ORCH_ENTRYPOINT` - `ORCH_ENTRYPOINT`
### Общие ### Общие
- `HY2XS_CONFIG_SCHEMA_VERSION`
- `DEPLOY_DOMAIN` - `DEPLOY_DOMAIN`
- `PUBLIC_HOST` - `PUBLIC_HOST`
- `PUBLIC_PORT` - `PUBLIC_PORT`
@@ -71,7 +72,8 @@
### Hysteria ### Hysteria
- `HY2_SOURCE=official-upstream` - `HY2_SOURCE=official-upstream`
- `HY2_VERSION` - `HY2_VERSION` — фактически установленная версия
- `HY2_RESOLUTION` — как версия была выбрана при сборке: `latest-stable`, `pinned` или `override`
- `HY2_TLS_MODE` - `HY2_TLS_MODE`
- `HY2_ACME_EMAIL` - `HY2_ACME_EMAIL`
- `HY2_TLS_CERT_PATH` - `HY2_TLS_CERT_PATH`
@@ -81,11 +83,17 @@
- `HY2_AUTH_MODE` - `HY2_AUTH_MODE`
- `HY2_AUTH_URL` - `HY2_AUTH_URL`
- `HY2_TRAFFIC_STATS_LISTEN` - `HY2_TRAFFIC_STATS_LISTEN`
- `HY2_OBFS_TYPE` - `HY2_OBFS_TYPE``gecko` или `salamander`
- `HY2_OBFS_PASSWORD` - `HY2_OBFS_PASSWORD`
- `HY2_GECKO_MIN_PACKET_SIZE`
- `HY2_GECKO_MAX_PACKET_SIZE`
- `HY2_BANDWIDTH_UP` - `HY2_BANDWIDTH_UP`
- `HY2_BANDWIDTH_DOWN` - `HY2_BANDWIDTH_DOWN`
- `HY2_DISABLE_LOSS_COMPENSATION`
- `HY2_IGNORE_CLIENT_BANDWIDTH` - `HY2_IGNORE_CLIENT_BANDWIDTH`
- `HY2_CONGESTION_TYPE`
- `HY2_BBR_PROFILE`
- `HY2_DISABLE_STATELESS_RESET`
- `HY2_CONFIG_PATH` - `HY2_CONFIG_PATH`
### HY2XS admin ### HY2XS admin
+198
View File
@@ -4,6 +4,24 @@
Зафиксировать checklist для новой двухслойной схемы. Зафиксировать 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 ## A. Builder layer tests
### Проверяем ### Проверяем
@@ -16,6 +34,81 @@
7. builder сам доставляет отсутствующие build-зависимости 7. builder сам доставляет отсутствующие build-зависимости
8. builder проверяет версии Go/Bun/Node.js/pnpm 8. builder проверяет версии Go/Bun/Node.js/pnpm
9. builder пишет версии toolchain в metadata 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 ## B. Target install tests
@@ -56,6 +149,89 @@
21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin` 21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin`
22. `hy2xs-admin` может читать `/etc/hysteria/config.yaml`, но не может писать 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 ## D. Negative tests
1. не Debian 13 1. не Debian 13
@@ -68,6 +244,9 @@
8. install flow прерван посередине 8. install flow прерван посередине
9. попытка использовать `HY2XS_IPV6_ENABLED=true` 9. попытка использовать `HY2XS_IPV6_ENABLED=true`
10. `HY2XS_PUBLIC_HOST=0.0.0.0` 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 (обязательные сценарии) ## E. Fix20 production matrix (обязательные сценарии)
@@ -119,3 +298,22 @@
13. production build verify падает, если `config/hy2xs.env` содержит placeholder-значения 13. production build verify падает, если `config/hy2xs.env` содержит placeholder-значения
14. production build verify падает при dirty git tree (кроме `ALLOW_DIRTY_BUILD=true`) 14. production build verify падает при dirty git tree (кроме `ALLOW_DIRTY_BUILD=true`)
15. metadata содержит `source_git_commit`, `dirty_tree`, `build_profile=production` 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. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
+7 -2
View File
@@ -24,10 +24,12 @@
Здесь нет сборщика. Здесь запускается только итоговый install package / orchestrator, который: Здесь нет сборщика. Здесь запускается только итоговый install package / orchestrator, который:
- ставит системные зависимости - ставит системные зависимости
- разворачивает **встроенный HY2XS admin** - разворачивает **встроенный HY2XS admin**
- забирает **свежую Hysteria2 из официального upstream** - забирает **закреплённую в пакете Hysteria2 из официального upstream** и сверяет её по SHA-256 и версии
- создаёт конфиги, systemd unit-файлы и `post-install.env` - создаёт конфиги, systemd unit-файлы и `post-install.env`
- выполняет базовую настройку сервера - выполняет базовую настройку сервера
Версия Hysteria2 выбирается **на builder layer**: последняя стабильная разрешается при сборке и замораживается в metadata пакета. Target layer никогда не обращается к moving `latest`.
## Базовые правила ## Базовые правила
1. Hysteria2 не вендорится и не собирается как часть HY2XS. 1. Hysteria2 не вендорится и не собирается как часть HY2XS.
@@ -52,6 +54,9 @@
10. [10-access-layer-out-of-scope.md](10-access-layer-out-of-scope.md) 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) 11. [11-testing-and-acceptance.md](11-testing-and-acceptance.md)
12. [12-operations-and-troubleshooting.md](12-operations-and-troubleshooting.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 ## Жёсткие рамки baseline
@@ -72,4 +77,4 @@
Правильная baseline-модель теперь такая: Правильная 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 и подготавливает рабочее серверное окружение.**
+1
View File
@@ -7,6 +7,7 @@
"packageManager": "bun@1.3.13", "packageManager": "bun@1.3.13",
"scripts": { "scripts": {
"check": "tsc --noEmit", "check": "tsc --noEmit",
"test": "bun test",
"build": "bun build src/cli.ts --compile --target=bun-linux-x64 --outfile dist/hy2xs-orchestrator" "build": "bun build src/cli.ts --compile --target=bun-linux-x64 --outfile dist/hy2xs-orchestrator"
}, },
"devDependencies": { "devDependencies": {
+179
View File
@@ -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");
}
+2 -1
View File
@@ -69,7 +69,8 @@ export async function doctor(options: ReconfigureOptions): Promise<void> {
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"), packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(), installDate: new Date().toISOString(),
hysteriaVersion: await readInstalledHysteriaVersion() hysteriaVersion: await readInstalledHysteriaVersion(),
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
}; };
step("doctor preflight"); step("doctor preflight");
+1
View File
@@ -159,6 +159,7 @@ export async function install(options: InstallOptions): Promise<void> {
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(), installDate: new Date().toISOString(),
hysteriaVersion: "unknown", hysteriaVersion: "unknown",
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown"),
hysteriaTargetVersion: await readPackageValue(options.packageDir, "hysteria.version", ""), hysteriaTargetVersion: await readPackageValue(options.packageDir, "hysteria.version", ""),
hysteriaArtifactUrl: await readPackageValue(options.packageDir, "hysteria.url", ""), hysteriaArtifactUrl: await readPackageValue(options.packageDir, "hysteria.url", ""),
hysteriaArtifactSha256: await readPackageValue(options.packageDir, "hysteria.sha256", "") hysteriaArtifactSha256: await readPackageValue(options.packageDir, "hysteria.sha256", "")
+2 -1
View File
@@ -176,7 +176,8 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"), packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(), installDate: new Date().toISOString(),
hysteriaVersion: await readInstalledHysteriaVersion() hysteriaVersion: await readInstalledHysteriaVersion(),
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
}; };
step("preflight"); step("preflight");
+35 -9
View File
@@ -1,5 +1,12 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { FirewallMode, RuntimeConfig, TlsMode } from "../types/context"; 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<string, string>; type EnvMap = Record<string, string>;
@@ -143,12 +150,22 @@ function normalizeFixedHysteriaAuthMode(value: string | undefined): "http" {
return "http"; return "http";
} }
function normalizeFixedHysteriaObfsType(value: string | undefined): "salamander" { function normalizeConfigSchemaVersion(value: string | undefined): number {
const obfsType = value || "salamander"; const raw = (value ?? "").trim();
if (obfsType !== "salamander") { if (!raw) {
throw new Error("HY2XS_HYSTERIA_OBFS_TYPE is fixed in HY2XS production profile: salamander"); 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 { 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 dnsAaaaPolicy = normalizeDnsAaaaPolicy(env.HY2XS_DNS_AAAA_POLICY);
const firewallMode = normalizeFirewallMode(env); const firewallMode = normalizeFirewallMode(env);
const hysteriaAuthMode = normalizeFixedHysteriaAuthMode(env.HY2XS_HYSTERIA_AUTH_MODE); 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 = { const config: RuntimeConfig = {
configSchemaVersion: normalizeConfigSchemaVersion(env.HY2XS_CONFIG_SCHEMA_VERSION),
domain: env.HY2XS_DOMAIN || "", domain: env.HY2XS_DOMAIN || "",
dnsAaaaPolicy, dnsAaaaPolicy,
publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""), publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""),
@@ -232,6 +250,10 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
"HY2XS_HYSTERIA_OBFS_PASSWORD", "HY2XS_HYSTERIA_OBFS_PASSWORD",
valueOrGenerate(env.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", hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps", hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
hysteriaIgnoreClientBandwidth: parseBool( hysteriaIgnoreClientBandwidth: parseBool(
@@ -251,6 +273,9 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
} }
export function validateRuntimeConfig(config: RuntimeConfig): void { export function validateRuntimeConfig(config: RuntimeConfig): void {
if (config.hysteriaObfsType === "gecko") {
validateGeckoPacketSizes(config.hysteriaGeckoMinPacketSize, config.hysteriaGeckoMaxPacketSize);
}
if (config.ipv6Enabled) { if (config.ipv6Enabled) {
throw new Error("HY2XS is IPv4-only: HY2XS_IPV6_ENABLED must be false"); 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 { export function renderRuntimeEnv(config: RuntimeConfig): string {
const lines = [ const lines = [
"# HY2XS runtime config (editable)", "# HY2XS runtime config (editable)",
"HY2XS_IPV6_ENABLED=false", `HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`,
`HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`,
`HY2XS_DOMAIN=${config.domain}`, `HY2XS_DOMAIN=${config.domain}`,
`HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`, `HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`,
`HY2XS_PUBLIC_HOST=${config.publicHost}`, `HY2XS_PUBLIC_HOST=${config.publicHost}`,
@@ -320,11 +346,11 @@ export function renderRuntimeEnv(config: RuntimeConfig): string {
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`, `HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`, `HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`, `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_HOST=${config.hysteriaTrafficStatsHost}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`, `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`, `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_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`, `HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`, `HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
+138
View File
@@ -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");
}
+26 -4
View File
@@ -1,8 +1,23 @@
import type { RuntimeContext } from "../types/context"; import type { RuntimeContext } from "../types/context";
import { readText, renderTemplate, writeText } from "../lib/fs"; import { readText, renderTemplate, writeText } from "../lib/fs";
import { runVisible } from "../lib/process"; import { runVisible } from "../lib/process";
import {
DISABLE_LOSS_COMPENSATION,
renderCongestionBlock,
renderObfsBlock,
renderQuicBlock
} from "../config/profile";
export async function generateConfig(context: RuntimeContext): Promise<void> { 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" 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}` ? `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<void> {
? `tls:\n cert: ${context.config.tlsCertPath}\n key: ${context.config.tlsKeyPath}` ? `tls:\n cert: ${context.config.tlsCertPath}\n key: ${context.config.tlsKeyPath}`
: ""; : "";
const template = await readText(`${context.options.packageDir}/templates/hysteria/config.yaml.tpl`); return renderTemplate(template, {
const rendered = renderTemplate(template, {
HYSTERIA_BIND_HOST: context.config.hysteriaBindHost, HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
HYSTERIA_PORT: context.config.hysteriaPort, HYSTERIA_PORT: context.config.hysteriaPort,
HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword,
HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost, HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost,
HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort, HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort,
HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret, HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret,
UI_PORT: context.config.uiPort, UI_PORT: context.config.uiPort,
BANDWIDTH_UP: context.config.hysteriaBandwidthUp, BANDWIDTH_UP: context.config.hysteriaBandwidthUp,
BANDWIDTH_DOWN: context.config.hysteriaBandwidthDown, BANDWIDTH_DOWN: context.config.hysteriaBandwidthDown,
DISABLE_LOSS_COMPENSATION: DISABLE_LOSS_COMPENSATION ? "true" : "false",
IGNORE_CLIENT_BANDWIDTH: context.config.hysteriaIgnoreClientBandwidth ? "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_ACME_BLOCK: tlsAcmeBlock,
TLS_FILE_BLOCK: tlsFileBlock, TLS_FILE_BLOCK: tlsFileBlock,
AUTH_INSECURE: context.config.tlsMode === "self_signed_dev" ? "true" : "false" AUTH_INSECURE: context.config.tlsMode === "self_signed_dev" ? "true" : "false"
}); });
}
export async function generateConfig(context: RuntimeContext): Promise<void> {
const template = await readText(hysteriaConfigTemplatePath(context.options.packageDir));
const rendered = renderHysteriaConfig(context, template);
const configPath = context.config.hysteriaConfigPath; const configPath = context.config.hysteriaConfigPath;
const tmpPath = `${configPath}.tmp`; const tmpPath = `${configPath}.tmp`;
+151
View File
@@ -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<string, unknown>;
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, "<root>");
}
/**
* Проверяет, что установленный конфиг действительно соответствует
* 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, "<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);
}
+15
View File
@@ -1,11 +1,18 @@
import type { RuntimeContext } from "../types/context"; import type { RuntimeContext } from "../types/context";
import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs"; import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs";
import { runVisible } from "../lib/process"; 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<void> { export async function writePostInstallEnv(context: RuntimeContext): Promise<void> {
const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/env/post-install.env.tpl`), { const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/env/post-install.env.tpl`), {
PACKAGE_VERSION: context.packageVersion, PACKAGE_VERSION: context.packageVersion,
PACKAGE_BUILD_ID: context.packageBuildId, PACKAGE_BUILD_ID: context.packageBuildId,
CONFIG_SCHEMA_VERSION: context.config.configSchemaVersion,
LAST_APPLY_DATE: context.installDate, LAST_APPLY_DATE: context.installDate,
DOMAIN: context.config.domain, DOMAIN: context.config.domain,
PUBLIC_HOST: context.config.publicHost, PUBLIC_HOST: context.config.publicHost,
@@ -14,16 +21,24 @@ export async function writePostInstallEnv(context: RuntimeContext): Promise<void
FIREWALL_MODE: context.config.firewallMode, FIREWALL_MODE: context.config.firewallMode,
FIREWALL_STAGED_APPLY: context.config.firewallStagedApply ? "true" : "false", FIREWALL_STAGED_APPLY: context.config.firewallStagedApply ? "true" : "false",
HYSTERIA_VERSION: context.hysteriaVersion, HYSTERIA_VERSION: context.hysteriaVersion,
HYSTERIA_RESOLUTION: context.hysteriaResolution,
TLS_MODE: context.config.tlsMode, TLS_MODE: context.config.tlsMode,
ACME_EMAIL: context.config.acmeEmail, ACME_EMAIL: context.config.acmeEmail,
TLS_CERT_PATH: context.config.tlsCertPath, TLS_CERT_PATH: context.config.tlsCertPath,
TLS_KEY_PATH: context.config.tlsKeyPath, TLS_KEY_PATH: context.config.tlsKeyPath,
HYSTERIA_BIND_HOST: context.config.hysteriaBindHost, HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
HYSTERIA_PORT: context.config.hysteriaPort, HYSTERIA_PORT: context.config.hysteriaPort,
OBFS_TYPE: context.config.hysteriaObfsType,
HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword, HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword,
GECKO_MIN_PACKET_SIZE: context.config.hysteriaGeckoMinPacketSize,
GECKO_MAX_PACKET_SIZE: context.config.hysteriaGeckoMaxPacketSize,
BANDWIDTH_UP: context.config.hysteriaBandwidthUp, BANDWIDTH_UP: context.config.hysteriaBandwidthUp,
BANDWIDTH_DOWN: context.config.hysteriaBandwidthDown, BANDWIDTH_DOWN: context.config.hysteriaBandwidthDown,
DISABLE_LOSS_COMPENSATION: DISABLE_LOSS_COMPENSATION ? "true" : "false",
IGNORE_CLIENT_BANDWIDTH: context.config.hysteriaIgnoreClientBandwidth ? "true" : "false", IGNORE_CLIENT_BANDWIDTH: context.config.hysteriaIgnoreClientBandwidth ? "true" : "false",
CONGESTION_TYPE,
BBR_PROFILE,
DISABLE_STATELESS_RESET: DISABLE_STATELESS_RESET ? "true" : "false",
HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost, HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost,
HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort, HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort,
HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret, HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret,
+30 -12
View File
@@ -1,6 +1,8 @@
import type { RuntimeContext } from "../types/context"; import type { RuntimeContext } from "../types/context";
import { info } from "../lib/log"; import { info } from "../lib/log";
import { readText } from "../lib/fs";
import { runHidden, runSecret, runVisible } from "../lib/process"; import { runHidden, runSecret, runVisible } from "../lib/process";
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
function parseLocalAddress(line: string): string { function parseLocalAddress(line: string): string {
const cols = line.trim().split(/\s+/); const cols = line.trim().split(/\s+/);
@@ -200,18 +202,34 @@ export async function smoke(context: RuntimeContext): Promise<void> {
await runVisible`nft -c -f /etc/nftables.conf`; await runVisible`nft -c -f /etc/nftables.conf`;
if (context.config.tlsMode === "acme") { // Семантическая проверка установленного конфига: разбираем YAML и сверяем
await runVisible`grep -q '^acme:' /etc/hysteria/config.yaml`; // с production-профилем, а не ищем подстроки.
await runVisible`! grep -q '^tls:' /etc/hysteria/config.yaml`; 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<void> {
const packagedVersion = context.hysteriaVersion.trim();
if (!packagedVersion || packagedVersion === "unknown") {
return;
} }
if (context.config.tlsMode === "file") {
await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`; const raw = await runSecret`/usr/local/bin/hysteria version`;
await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`; const match = raw.match(/v\d+\.\d+\.\d+/);
await runVisible`grep -q 'insecure: false' /etc/hysteria/config.yaml`; const effective = match ? match[0] : raw.trim();
}
if (context.config.tlsMode === "self_signed_dev") { if (effective !== packagedVersion) {
await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`; const message = `effective Hysteria version ${effective} does not match package metadata ${packagedVersion}`;
await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`; if (context.mode === "install") {
await runVisible`grep -q 'insecure: true' /etc/hysteria/config.yaml`; throw new Error(message);
}
info(`warning: ${message}`);
} }
} }
+8 -1
View File
@@ -24,7 +24,10 @@ export type TlsMode = "acme" | "file" | "self_signed_dev";
export type DnsAaaaPolicy = "strict" | "warn" | "off"; export type DnsAaaaPolicy = "strict" | "warn" | "off";
export type HysteriaObfsType = "gecko" | "salamander";
export type RuntimeConfig = { export type RuntimeConfig = {
configSchemaVersion: number;
domain: string; domain: string;
dnsAaaaPolicy: DnsAaaaPolicy; dnsAaaaPolicy: DnsAaaaPolicy;
publicHost: string; publicHost: string;
@@ -52,8 +55,10 @@ export type RuntimeConfig = {
hysteriaTrafficStatsHost: string; hysteriaTrafficStatsHost: string;
hysteriaTrafficStatsPort: number; hysteriaTrafficStatsPort: number;
hysteriaTrafficStatsSecret: string; hysteriaTrafficStatsSecret: string;
hysteriaObfsType: "salamander"; hysteriaObfsType: HysteriaObfsType;
hysteriaObfsPassword: string; hysteriaObfsPassword: string;
hysteriaGeckoMinPacketSize: number;
hysteriaGeckoMaxPacketSize: number;
hysteriaBandwidthUp: string; hysteriaBandwidthUp: string;
hysteriaBandwidthDown: string; hysteriaBandwidthDown: string;
hysteriaIgnoreClientBandwidth: boolean; hysteriaIgnoreClientBandwidth: boolean;
@@ -72,6 +77,8 @@ export type RuntimeContext = {
packageBuildId: string; packageBuildId: string;
installDate: string; installDate: string;
hysteriaVersion: string; hysteriaVersion: string;
/** Как версия Hysteria была выбрана на этапе сборки пакета: latest-stable | pinned | override. */
hysteriaResolution: string;
}; };
export type InstallContext = RuntimeContext & { export type InstallContext = RuntimeContext & {
+124
View File
@@ -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<string, string | null> = {}) {
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();
});
});
+160
View File
@@ -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/);
});
});
+102
View File
@@ -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, string | null> = {}): string {
const lines: string[] = [];
const applied = new Set<string>();
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<string, string | null> = {}): 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"
};
}
+220
View File
@@ -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> = {}): 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/
);
}
});
});
@@ -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 -");
});
});
+141
View File
@@ -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, string | null> = {}): 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}}");
});
});
@@ -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<string, keyof Options> = {
"--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, string>): 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<void> {
const options = parseArgs(Bun.argv.slice(2));
const overrides: Record<string, string> = {
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));
}
+124
View File
@@ -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<GithubRelease[]> {
const headers: Record<string, string> = {
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<void> {
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();
+7 -2
View File
@@ -1,4 +1,7 @@
# HY2XS canonical production runtime config (packaged baseline) # HY2XS canonical production runtime config (packaged baseline)
# Версия схемы конфигурации HY2XS. Пакет понимает только эту версию;
# установка поверх сервера со схемой v0 не поддерживается — нужна чистая установка.
HY2XS_CONFIG_SCHEMA_VERSION=2
HY2XS_IPV6_ENABLED=false HY2XS_IPV6_ENABLED=false
HY2XS_DOMAIN=fi.api.withen.pro HY2XS_DOMAIN=fi.api.withen.pro
HY2XS_DNS_AAAA_POLICY=strict 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_HOST=127.0.0.1
HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712 HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712
HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=__GENERATE__ HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=__GENERATE__
# fixed in production profile: must remain salamander # gecko | salamander. Gecko — production default HY2XS (upstream помечает его experimental).
HY2XS_HYSTERIA_OBFS_TYPE=salamander # Salamander остаётся полностью поддержанным compatibility fallback.
# Смена значения меняет wire-совместимость: клиентам нужна новая ссылка.
HY2XS_HYSTERIA_OBFS_TYPE=gecko
HY2XS_HYSTERIA_OBFS_PASSWORD=__GENERATE__ HY2XS_HYSTERIA_OBFS_PASSWORD=__GENERATE__
HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps
HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps
+9 -1
View File
@@ -6,6 +6,7 @@ DEPLOY_TIMESTAMP={{LAST_APPLY_DATE}}
PACKAGE_NAME=hy2xs-install-package PACKAGE_NAME=hy2xs-install-package
PACKAGE_BUILD_ID={{PACKAGE_BUILD_ID}} PACKAGE_BUILD_ID={{PACKAGE_BUILD_ID}}
PACKAGE_VERSION={{PACKAGE_VERSION}} PACKAGE_VERSION={{PACKAGE_VERSION}}
HY2XS_CONFIG_SCHEMA_VERSION={{CONFIG_SCHEMA_VERSION}}
ORCH_SOURCE_STACK=bun-typescript ORCH_SOURCE_STACK=bun-typescript
ORCH_BUILD_MODE=bun-compile ORCH_BUILD_MODE=bun-compile
@@ -21,6 +22,7 @@ HY2XS_FIREWALL_STAGED_APPLY={{FIREWALL_STAGED_APPLY}}
HY2_SOURCE=official-upstream HY2_SOURCE=official-upstream
HY2_VERSION={{HYSTERIA_VERSION}} HY2_VERSION={{HYSTERIA_VERSION}}
HY2_RESOLUTION={{HYSTERIA_RESOLUTION}}
HY2_TLS_MODE={{TLS_MODE}} HY2_TLS_MODE={{TLS_MODE}}
HY2_ACME_EMAIL={{ACME_EMAIL}} HY2_ACME_EMAIL={{ACME_EMAIL}}
HY2_TLS_CERT_PATH={{TLS_CERT_PATH}} HY2_TLS_CERT_PATH={{TLS_CERT_PATH}}
@@ -30,11 +32,17 @@ HY2_PORT={{HYSTERIA_PORT}}
HY2_AUTH_MODE=http HY2_AUTH_MODE=http
HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}} 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_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_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_UP={{BANDWIDTH_UP}}
HY2_BANDWIDTH_DOWN={{BANDWIDTH_DOWN}} HY2_BANDWIDTH_DOWN={{BANDWIDTH_DOWN}}
HY2_DISABLE_LOSS_COMPENSATION={{DISABLE_LOSS_COMPENSATION}}
HY2_IGNORE_CLIENT_BANDWIDTH={{IGNORE_CLIENT_BANDWIDTH}} 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 HY2_CONFIG_PATH=/etc/hysteria/config.yaml
HY2XS_ADMIN_ENABLED=true HY2XS_ADMIN_ENABLED=true
+5 -9
View File
@@ -9,23 +9,19 @@ auth:
url: http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}} url: http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}
insecure: {{AUTH_INSECURE}} insecure: {{AUTH_INSECURE}}
obfs: {{OBFS_BLOCK}}
type: salamander
salamander:
password: "{{HYSTERIA_OBFS_PASSWORD}}"
bandwidth: bandwidth:
up: "{{BANDWIDTH_UP}}" up: "{{BANDWIDTH_UP}}"
down: "{{BANDWIDTH_DOWN}}" down: "{{BANDWIDTH_DOWN}}"
disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}
ignoreClientBandwidth: {{IGNORE_CLIENT_BANDWIDTH}} ignoreClientBandwidth: {{IGNORE_CLIENT_BANDWIDTH}}
{{CONGESTION_BLOCK}}
trafficStats: trafficStats:
listen: {{HYSTERIA_API_HOST}}:{{HYSTERIA_API_PORT}} listen: {{HYSTERIA_API_HOST}}:{{HYSTERIA_API_PORT}}
secret: "{{HYSTERIA_API_SECRET}}" secret: "{{HYSTERIA_API_SECRET}}"
quic: {{QUIC_BLOCK}}
initStreamReceiveWindow: 8388608
maxStreamReceiveWindow: 8388608
initConnReceiveWindow: 20971520
maxConnReceiveWindow: 20971520
+97 -34
View File
@@ -20,7 +20,10 @@ dist/hy2xs-install-<version>.tar.gz
- [`tools/build/lib/deps.sh`](lib/deps.sh) — проверка Debian/amd64, установка build dependencies, установка Go/Bun/Node.js/pnpm. - [`tools/build/lib/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/package.sh`](lib/package.sh) — сборка orchestrator, сборка HY2XS admin, создание stage directory и tar.gz архива.
- [`tools/build/lib/verify.sh`](lib/verify.sh) — проверка структуры репозитория и итогового архива. - [`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. - [`orchestrator`](../../orchestrator) — TypeScript/Bun install-only orchestrator.
- [`apps`](../../apps) — HY2XS admin: Go backend и Vue frontend. - [`apps`](../../apps) — HY2XS admin: Go backend и Vue frontend.
- [`package`](../../package) — skeleton будущего install package: `install.sh`, templates, systemd units, default config. - [`package`](../../package) — skeleton будущего install package: `install.sh`, templates, systemd units, default config.
@@ -58,13 +61,73 @@ Windows и macOS можно использовать для редактиров
- Bun `1.3.13`; - Bun `1.3.13`;
- Node.js `20.19.0`; - Node.js `20.19.0`;
- pnpm `9.15.9`. - pnpm `9.15.9`.
5. Собирает install-only orchestrator в standalone binary. 5. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`).
6. Собирает frontend HY2XS admin. 6. Разрешает upstream-версию Hysteria, скачивает артефакт и считает SHA-256.
7. Собирает backend HY2XS admin в Linux amd64 binary. 7. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
8. Копирует package skeleton. 8. Копирует package skeleton.
9. Записывает metadata и checksums. 9. Собирает install-only orchestrator в standalone binary.
10. Создаёт `dist/hy2xs-install-<version>.tar.gz`. 10. Собирает frontend и backend HY2XS admin в Linux amd64 binary.
11. Проверяет, что архив содержит обязательные файлы. 11. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
12. Записывает metadata и checksums.
13. Создаёт `dist/hy2xs-install-<version>.tar.gz`.
14. Проверяет архив и прогоняет acceptance-проверки.
## Версия Hysteria: разрешение и compatibility gate
Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете.
Правила разрешения:
1. канонический upstream — `HyNetworks/hysteria`;
2. принимаются только стабильные релизы, без draft и prerelease;
3. тег должен иметь вид `app/vX.Y.Z`;
4. берётся ровно один артефакт `hysteria-linux-amd64`;
5. URL используется в том виде, в каком его вернул upstream API, без пересборки строки;
6. SHA-256 считается локально от скачанного файла;
7. разрешённые значения попадают в metadata пакета.
Сравнение версий числовое, поэтому `v2.9.10` считается новее `v2.9.2`.
После разрешения обязателен compatibility gate:
```text
скачать бинарник
сверить SHA-256 и `hysteria version`
отрендерить канонический конфиг HY2XS тем же кодом, что и на target
запустить настоящий Hysteria с этим конфигом (gecko и salamander)
только после этого собирать release package
```
При несовместимости сборка останавливается:
```text
BUILD FAILED: unsupported Hysteria stable v2.13.0
```
Это осознанное решение: ошибка должна проявиться на build machine, а не на production-сервере.
Переменные:
| Переменная | По умолчанию | Назначение |
| --- | --- | --- |
| `HYSTERIA_CHANNEL` | `stable` | `stable` — разрешить последнюю стабильную через upstream API; `pinned` — офлайн-сборка по `hysteria-lock.env` |
| `HYSTERIA_VERSION_OVERRIDE` | пусто | Закрепить конкретную версию `vX.Y.Z` |
| `HYSTERIA_COMPAT_GATE` | `true` | Compatibility gate; для release-сборок обязателен |
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в `hysteria-lock.env` |
| `HYSTERIA_GATE_PORT` | `34443` | UDP-порт для временного запуска Hysteria в gate |
| `HYSTERIA_GATE_STATS_PORT` | `34712` | TCP-порт trafficStats в gate |
| `GITHUB_TOKEN` | пусто | Опционально: снимает anonymous rate limit GitHub API |
| `SKIP_TESTS` | `false` | Аварийное отключение тестов; для release-сборок недопустимо |
Обновить lock-файл под текущий upstream:
```bash
HYSTERIA_WRITE_LOCK=true ./tools/build/build.sh
```
## Важное про Bun и старые CPU ## Важное про Bun и старые CPU
@@ -111,7 +174,7 @@ BUN_FLAVOR=x64-baseline ./tools/build/build.sh
С явной версией и build id: С явной версией и build id:
```bash ```bash
PACKAGE_VERSION=0.2.1 \ PACKAGE_VERSION=1.0.0 \
BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ) \ BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ) \
./tools/build/build.sh ./tools/build/build.sh
``` ```
@@ -121,59 +184,59 @@ BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ) \
Проверка архива: Проверка архива:
```bash ```bash
ls -lh dist/hy2xs-install-0.2.1.tar.gz ls -lh dist/hy2xs-install-1.0.0.tar.gz
sha256sum dist/hy2xs-install-0.2.1.tar.gz | tee dist/hy2xs-install-0.2.1.tar.gz.sha256 sha256sum dist/hy2xs-install-1.0.0.tar.gz | tee dist/hy2xs-install-1.0.0.tar.gz.sha256
``` ```
Проверка обязательных файлов: Проверка обязательных файлов:
```bash ```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: Проверка metadata:
```bash ```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; - изменился [`orchestrator/package.json`](../../orchestrator/package.json);
- `BUN_REQUIRED` changes in [`tools/build/lib/deps.sh`](lib/deps.sh); - изменился `BUN_REQUIRED` в [`tools/build/lib/deps.sh`](lib/deps.sh);
- orchestrator dependencies are intentionally upgraded. - зависимости оркестратора обновляются осознанно.
Production builder always runs: Production builder всегда выполняет:
```bash ```bash
bun install --frozen-lockfile 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"`; - [`apps/frontend/package.json`](../../apps/frontend/package.json) объявляет `"packageManager": "pnpm@9.15.9"`;
- builder uses pinned pnpm `9.15.9` from [`PNPM_REQUIRED`](lib/deps.sh); - builder использует закреплённый pnpm `9.15.9` из [`PNPM_REQUIRED`](lib/deps.sh);
- production frontend install path is always: - production-путь установки frontend всегда:
```bash ```bash
pnpm install --frozen-lockfile 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)` - `BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ)`
- `BUN_FLAVOR=auto|x64|x64-baseline` - `BUN_FLAVOR=auto|x64|x64-baseline`
- `FRONTEND_NODE_OLD_SPACE_SIZE=2048` (default memory limit for frontend build step) - `FRONTEND_NODE_OLD_SPACE_SIZE=2048` (default memory limit for frontend build step)
@@ -186,23 +249,23 @@ If frozen install fails, update dependencies intentionally in source control and
- `NODE_ARCHIVE_SHA256=<sha256>` - `NODE_ARCHIVE_SHA256=<sha256>`
- `BUN_ARCHIVE_SHA256=<sha256>` - `BUN_ARCHIVE_SHA256=<sha256>`
## Frontend memory policy ## Политика памяти при сборке frontend
Builder applies a safe default Node.js heap limit for frontend build inside [`bundle_ui()`](lib/package.sh): Builder задаёт безопасный лимит heap для Node.js внутри [`bundle_ui()`](lib/package.sh):
```bash ```bash
--max-old-space-size=2048 --max-old-space-size=2048
``` ```
Override options: Способы переопределения:
- adjust default value for this policy: - изменить значение по умолчанию для этой политики:
```bash ```bash
FRONTEND_NODE_OLD_SPACE_SIZE=3072 ./tools/build/build.sh 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 ```bash
NODE_OPTIONS="--max-old-space-size=3072" ./tools/build/build.sh 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 -rf .toolchain/bun .toolchain/bun-tmp
rm -f .toolchain/downloads/bun-linux-x64-*.zip rm -f .toolchain/downloads/bun-linux-x64-*.zip
rm -f .toolchain/downloads/bun-linux-x64-baseline-*.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: Проверить shell syntax:
+14 -1
View File
@@ -10,6 +10,8 @@ BUILD_DIR="$ROOT_DIR/tools/build"
. "$BUILD_DIR/lib/deps.sh" . "$BUILD_DIR/lib/deps.sh"
# shellcheck source=tools/build/lib/verify.sh # shellcheck source=tools/build/lib/verify.sh
. "$BUILD_DIR/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 # shellcheck source=tools/build/lib/acceptance.sh
. "$BUILD_DIR/lib/acceptance.sh" . "$BUILD_DIR/lib/acceptance.sh"
# shellcheck source=tools/build/lib/package.sh # shellcheck source=tools/build/lib/package.sh
@@ -24,9 +26,15 @@ main() {
ensure_build_dependencies ensure_build_dependencies
ensure_toolchain 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)}" 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" log_step "Preparing package stage"
prepare_stage "$PACKAGE_VERSION" "$BUILD_ID" prepare_stage "$PACKAGE_VERSION" "$BUILD_ID"
@@ -36,6 +44,11 @@ main() {
log_step "Bundling HY2XS admin" log_step "Bundling HY2XS admin"
bundle_ui bundle_ui
# Тесты админки идут после bundle_ui: `go:embed all:dist` требует уже
# собранных frontend-ассетов, которых нет в git.
log_step "Checking HY2XS admin contracts"
run_admin_tests
log_step "Writing metadata" log_step "Writing metadata"
write_metadata "$PACKAGE_VERSION" "$BUILD_ID" write_metadata "$PACKAGE_VERSION" "$BUILD_ID"
+7 -3
View File
@@ -1,3 +1,7 @@
HYSTERIA_VERSION=v2.8.2 # Файл генерируется tools/build/lib/hysteria.sh.
HYSTERIA_ARTIFACT_URL=https://github.com/apernet/hysteria/releases/download/app%2Fv2.8.2/hysteria-linux-amd64 # HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.
HYSTERIA_ARTIFACT_SHA256=b11bf0fb5f84a3f5c6baff3696e899539e68af4cee868c9203cfb896784ad3b0 HYSTERIA_VERSION=v2.12.2
HYSTERIA_ARTIFACT_URL=https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hysteria-linux-amd64
HYSTERIA_ARTIFACT_SHA256=6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94
HYSTERIA_RESOLUTION=latest-stable
HYSTERIA_RESOLVED_AT=2026-08-27T00:00:00Z
+81
View File
@@ -30,6 +30,87 @@ run_fix20_acceptance_subset() {
log_step "Acceptance: force password change production default" 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" 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" 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" grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing"
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env bash
set -euo pipefail
# Разрешение upstream-версии Hysteria и compatibility gate.
#
# Ключевой инвариант: "latest" вычисляется на build machine и замораживается
# в metadata пакета. Target-сервер никогда не обращается к moving "latest".
HYSTERIA_LOCK_FILE="tools/build/hysteria-lock.env"
HYSTERIA_WORK_DIR="tools/build/output/hysteria"
# stable — определить последнюю стабильную версию через upstream API (default);
# pinned — использовать закоммиченный tools/build/hysteria-lock.env без сети.
HYSTERIA_CHANNEL="${HYSTERIA_CHANNEL:-stable}"
# Явный пин конкретной версии вида v2.12.2. Побеждает автоматический выбор.
HYSTERIA_VERSION_OVERRIDE="${HYSTERIA_VERSION_OVERRIDE:-}"
# Обновлять ли tools/build/hysteria-lock.env резолвнутыми значениями.
HYSTERIA_WRITE_LOCK="${HYSTERIA_WRITE_LOCK:-false}"
# Compatibility gate обязателен для production-сборки.
HYSTERIA_COMPAT_GATE="${HYSTERIA_COMPAT_GATE:-true}"
HYSTERIA_GATE_PORT="${HYSTERIA_GATE_PORT:-34443}"
HYSTERIA_GATE_STATS_PORT="${HYSTERIA_GATE_STATS_PORT:-34712}"
HYSTERIA_GATE_TIMEOUT_SECONDS="${HYSTERIA_GATE_TIMEOUT_SECONDS:-15}"
validate_hysteria_version_format() {
local value="$1"
printf '%s' "$value" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "invalid Hysteria version format: $value (expected vX.Y.Z)"
}
validate_hysteria_sha256_format() {
local value="$1"
case "$value" in
replace-with-*|"")
fail "HYSTERIA_ARTIFACT_SHA256 must be a real release sha256"
;;
esac
printf '%s' "$value" | grep -Eq '^[a-fA-F0-9]{64}$' \
|| fail "HYSTERIA_ARTIFACT_SHA256 must be a 64-char hex SHA256, got: $value"
}
load_pinned_hysteria_lock() {
[ -f "$HYSTERIA_LOCK_FILE" ] || fail "missing Hysteria lock file: $HYSTERIA_LOCK_FILE"
# shellcheck disable=SC1090
. "$HYSTERIA_LOCK_FILE"
[ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION is required in $HYSTERIA_LOCK_FILE"
[ -n "${HYSTERIA_ARTIFACT_URL:-}" ] || fail "HYSTERIA_ARTIFACT_URL is required in $HYSTERIA_LOCK_FILE"
[ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 is required in $HYSTERIA_LOCK_FILE"
validate_hysteria_version_format "$HYSTERIA_VERSION"
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
HYSTERIA_RESOLUTION="pinned"
HYSTERIA_RESOLVED_AT="${HYSTERIA_RESOLVED_AT:-unknown}"
export HYSTERIA_VERSION HYSTERIA_ARTIFACT_URL HYSTERIA_ARTIFACT_SHA256 HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT
}
resolve_hysteria_from_upstream() {
local resolver_args=(--channel stable)
if [ -n "$HYSTERIA_VERSION_OVERRIDE" ]; then
validate_hysteria_version_format "$HYSTERIA_VERSION_OVERRIDE"
resolver_args+=(--version "$HYSTERIA_VERSION_OVERRIDE")
log_info "Resolving pinned Hysteria version override: $HYSTERIA_VERSION_OVERRIDE"
else
log_info "Resolving latest stable Hysteria release from upstream"
fi
local resolver_output
resolver_output="$(cd orchestrator && "$BUN_BIN" run tools/resolve-hysteria.ts "${resolver_args[@]}")" \
|| fail "Hysteria release resolution failed"
local key value line
HYSTERIA_VERSION=""
HYSTERIA_TAG=""
HYSTERIA_ARTIFACT_URL=""
HYSTERIA_PUBLISHED_AT=""
HYSTERIA_RESOLUTION=""
while IFS= read -r line; do
[ -n "$line" ] || continue
key="${line%%=*}"
value="${line#*=}"
case "$key" in
HYSTERIA_VERSION) HYSTERIA_VERSION="$value" ;;
HYSTERIA_TAG) HYSTERIA_TAG="$value" ;;
HYSTERIA_ARTIFACT_URL) HYSTERIA_ARTIFACT_URL="$value" ;;
HYSTERIA_PUBLISHED_AT) HYSTERIA_PUBLISHED_AT="$value" ;;
HYSTERIA_RESOLUTION) HYSTERIA_RESOLUTION="$value" ;;
*) fail "unexpected resolver output line: $line" ;;
esac
done <<<"$resolver_output"
[ -n "$HYSTERIA_VERSION" ] || fail "resolver did not return HYSTERIA_VERSION"
[ -n "$HYSTERIA_ARTIFACT_URL" ] || fail "resolver did not return HYSTERIA_ARTIFACT_URL"
validate_hysteria_version_format "$HYSTERIA_VERSION"
HYSTERIA_RESOLVED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
log_info "Resolved Hysteria $HYSTERIA_VERSION (tag ${HYSTERIA_TAG:-unknown}, published ${HYSTERIA_PUBLISHED_AT:-unknown})"
export HYSTERIA_VERSION HYSTERIA_TAG HYSTERIA_ARTIFACT_URL HYSTERIA_PUBLISHED_AT
export HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT
}
download_hysteria_artifact() {
mkdir -p "$HYSTERIA_WORK_DIR"
HYSTERIA_BINARY="$HYSTERIA_WORK_DIR/hysteria-linux-amd64"
log_info "Downloading $HYSTERIA_ARTIFACT_URL"
rm -f "$HYSTERIA_BINARY"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \
"$HYSTERIA_ARTIFACT_URL" -o "$HYSTERIA_BINARY" \
|| fail "failed to download Hysteria artifact: $HYSTERIA_ARTIFACT_URL"
[ -s "$HYSTERIA_BINARY" ] || fail "downloaded Hysteria artifact is empty"
local actual_sha
actual_sha="$(sha256sum "$HYSTERIA_BINARY" | awk '{print $1}')"
if [ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ]; then
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
[ "$actual_sha" = "$HYSTERIA_ARTIFACT_SHA256" ] \
|| fail "Hysteria artifact sha256 mismatch: expected $HYSTERIA_ARTIFACT_SHA256, got $actual_sha"
else
HYSTERIA_ARTIFACT_SHA256="$actual_sha"
log_info "Computed Hysteria artifact sha256: $HYSTERIA_ARTIFACT_SHA256"
fi
export HYSTERIA_ARTIFACT_SHA256 HYSTERIA_BINARY
chmod 0755 "$HYSTERIA_BINARY"
local reported_version
reported_version="$("$HYSTERIA_BINARY" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
[ -n "$reported_version" ] || fail "unable to read version from downloaded Hysteria binary"
[ "$reported_version" = "$HYSTERIA_VERSION" ] \
|| fail "downloaded Hysteria binary reports $reported_version, expected $HYSTERIA_VERSION"
log_info "Verified Hysteria binary: $HYSTERIA_VERSION ($HYSTERIA_ARTIFACT_SHA256)"
}
assert_gate_port_is_free() {
local port="$1"
local proto_flag="$2"
if command -v ss >/dev/null 2>&1; then
if ss -H "$proto_flag" 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
fail "compatibility gate port $port is already in use; override HYSTERIA_GATE_PORT/HYSTERIA_GATE_STATS_PORT"
fi
fi
}
gate_generate_self_signed_cert() {
local dir="$1"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj /CN=hy2xs-compat-gate \
-keyout "$dir/gate.key" -out "$dir/gate.crt" >/dev/null 2>&1 \
|| fail "compatibility gate could not generate a self-signed certificate"
}
gate_run_single_profile() {
local obfs_type="$1"
local gate_dir="$2"
local config_path="$gate_dir/config-$obfs_type.yaml"
local log_path="$gate_dir/hysteria-$obfs_type.log"
log_info "Compatibility gate: rendering canonical config (obfs=$obfs_type)"
(
cd orchestrator
"$BUN_BIN" run tools/render-canonical-config.ts \
--package-dir ../package \
--obfs "$obfs_type" \
--tls-mode file \
--cert "$gate_dir/gate.crt" \
--key "$gate_dir/gate.key" \
--port "$HYSTERIA_GATE_PORT" \
--traffic-stats-port "$HYSTERIA_GATE_STATS_PORT" \
--out "$config_path"
) || fail "BUILD FAILED: HY2XS could not render canonical config for obfs=$obfs_type"
grep -q "type: $obfs_type" "$config_path" \
|| fail "compatibility gate rendered a config without obfs type $obfs_type"
log_info "Compatibility gate: starting Hysteria $HYSTERIA_VERSION with generated config (obfs=$obfs_type)"
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BINARY" server -c "$config_path" >"$log_path" 2>&1 &
local gate_pid=$!
local ready="false"
local waited=0
while [ "$waited" -lt "$HYSTERIA_GATE_TIMEOUT_SECONDS" ]; do
if ! kill -0 "$gate_pid" 2>/dev/null; then
break
fi
if command -v ss >/dev/null 2>&1 \
&& ss -H -lun 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${HYSTERIA_GATE_PORT}$"; then
ready="true"
break
fi
sleep 1
waited=$((waited + 1))
done
if [ "$ready" != "true" ]; then
kill "$gate_pid" 2>/dev/null || true
wait "$gate_pid" 2>/dev/null || true
log_info "--- Hysteria compatibility gate output (obfs=$obfs_type) ---"
cat "$log_path" >&2 || true
fail "BUILD FAILED: unsupported Hysteria stable $HYSTERIA_VERSION (obfs=$obfs_type config was rejected)"
fi
kill "$gate_pid" 2>/dev/null || true
wait "$gate_pid" 2>/dev/null || true
log_info "Compatibility gate passed for obfs=$obfs_type"
}
run_hysteria_compatibility_gate() {
if [ "$HYSTERIA_COMPAT_GATE" != "true" ]; then
log_info "WARNING: Hysteria compatibility gate is disabled (HYSTERIA_COMPAT_GATE=$HYSTERIA_COMPAT_GATE)"
return 0
fi
require_tool openssl
[ -x "${HYSTERIA_BINARY:-}" ] || fail "compatibility gate requires a verified Hysteria binary"
assert_gate_port_is_free "$HYSTERIA_GATE_PORT" "-lun"
assert_gate_port_is_free "$HYSTERIA_GATE_STATS_PORT" "-ltn"
local gate_dir
gate_dir="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '$gate_dir'" RETURN
gate_generate_self_signed_cert "$gate_dir"
gate_run_single_profile "gecko" "$gate_dir"
gate_run_single_profile "salamander" "$gate_dir"
log_info "Compatibility gate: Hysteria $HYSTERIA_VERSION accepts the HY2XS production config"
}
write_hysteria_lock_file() {
if [ "$HYSTERIA_WRITE_LOCK" != "true" ]; then
return 0
fi
log_info "Updating $HYSTERIA_LOCK_FILE with resolved values"
cat >"$HYSTERIA_LOCK_FILE" <<EOF
# Файл генерируется tools/build/lib/hysteria.sh.
# HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.
HYSTERIA_VERSION=$HYSTERIA_VERSION
HYSTERIA_ARTIFACT_URL=$HYSTERIA_ARTIFACT_URL
HYSTERIA_ARTIFACT_SHA256=$HYSTERIA_ARTIFACT_SHA256
HYSTERIA_RESOLUTION=$HYSTERIA_RESOLUTION
HYSTERIA_RESOLVED_AT=$HYSTERIA_RESOLVED_AT
EOF
}
resolve_and_verify_hysteria() {
case "$HYSTERIA_CHANNEL" in
stable)
resolve_hysteria_from_upstream
# sha256 считается локально от скачанного артефакта
HYSTERIA_ARTIFACT_SHA256=""
;;
pinned)
log_info "Using pinned Hysteria lock file (offline): $HYSTERIA_LOCK_FILE"
load_pinned_hysteria_lock
;;
*)
fail "unsupported HYSTERIA_CHANNEL: $HYSTERIA_CHANNEL (use stable or pinned)"
;;
esac
download_hysteria_artifact
run_hysteria_compatibility_gate
write_hysteria_lock_file
}
+44 -14
View File
@@ -3,7 +3,6 @@ set -euo pipefail
STAGE_DIR="tools/build/output/hy2xs-install" STAGE_DIR="tools/build/output/hy2xs-install"
ADMIN_BUILD_DIR="tools/build/output/hy2xs-admin-build" ADMIN_BUILD_DIR="tools/build/output/hy2xs-admin-build"
HYSTERIA_LOCK_FILE="tools/build/hysteria-lock.env"
prepare_stage() { prepare_stage() {
local version="$1" local version="$1"
@@ -27,6 +26,38 @@ prepare_stage() {
printf '%s\n' "$build_id" >"$STAGE_DIR/metadata/package.build_id" printf '%s\n' "$build_id" >"$STAGE_DIR/metadata/package.build_id"
} }
run_orchestrator_tests() {
if [ "${SKIP_TESTS:-false}" = "true" ]; then
log_info "WARNING: orchestrator tests skipped (SKIP_TESTS=true)"
return 0
fi
(
cd orchestrator
"$BUN_BIN" install --frozen-lockfile
"$BUN_BIN" x tsc --noEmit
"$BUN_BIN" test
) || fail "orchestrator contract tests failed"
}
run_admin_tests() {
if [ "${SKIP_TESTS:-false}" = "true" ]; then
log_info "WARNING: HY2XS admin tests skipped (SKIP_TESTS=true)"
return 0
fi
# `go:embed all:dist` требует собранных frontend-ассетов, поэтому эта
# функция должна вызываться только после bundle_ui.
[ -d "${UI_SRC:-apps}/frontend/dist" ] \
|| fail "run_admin_tests must run after bundle_ui: ${UI_SRC:-apps}/frontend/dist is missing"
(
cd "${UI_SRC:-apps}"
GOTOOLCHAIN=local "$GO_BIN" vet ./...
GOTOOLCHAIN=local "$GO_BIN" test ./...
) || fail "HY2XS admin contract tests failed"
}
build_orchestrator() { build_orchestrator() {
local bun_compile_target="${BUN_COMPILE_TARGET:-bun-linux-x64}" local bun_compile_target="${BUN_COMPILE_TARGET:-bun-linux-x64}"
@@ -88,19 +119,13 @@ write_metadata() {
dirty_tree="true" dirty_tree="true"
fi fi
[ -f "$HYSTERIA_LOCK_FILE" ] || fail "missing Hysteria lock file: $HYSTERIA_LOCK_FILE" # Значения приходят из resolve_and_verify_hysteria: они уже проверены
# shellcheck disable=SC1090 # по SHA-256, по `hysteria version` и по compatibility gate.
. "$HYSTERIA_LOCK_FILE" [ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION was not resolved before write_metadata"
[ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION is required in $HYSTERIA_LOCK_FILE" [ -n "${HYSTERIA_ARTIFACT_URL:-}" ] || fail "HYSTERIA_ARTIFACT_URL was not resolved before write_metadata"
[ -n "${HYSTERIA_ARTIFACT_URL:-}" ] || fail "HYSTERIA_ARTIFACT_URL is required in $HYSTERIA_LOCK_FILE" [ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 was not resolved before write_metadata"
[ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 is required in $HYSTERIA_LOCK_FILE" validate_hysteria_version_format "$HYSTERIA_VERSION"
case "$HYSTERIA_ARTIFACT_SHA256" in validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
replace-with-*|"")
fail "HYSTERIA_ARTIFACT_SHA256 must be a real release sha256"
;;
esac
printf '%s' "$HYSTERIA_ARTIFACT_SHA256" | grep -Eq '^[a-fA-F0-9]{64}$' \
|| fail "HYSTERIA_ARTIFACT_SHA256 must be a 64-char hex SHA256"
{ {
printf 'name=HY2XS\n' printf 'name=HY2XS\n'
@@ -127,11 +152,16 @@ write_metadata() {
printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION" printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION"
printf 'hysteria_artifact_url=%s\n' "$HYSTERIA_ARTIFACT_URL" printf 'hysteria_artifact_url=%s\n' "$HYSTERIA_ARTIFACT_URL"
printf 'hysteria_artifact_sha256=%s\n' "$HYSTERIA_ARTIFACT_SHA256" 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" } >"$STAGE_DIR/metadata/package.env"
printf '%s\n' "$HYSTERIA_VERSION" >"$STAGE_DIR/metadata/hysteria.version" 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_URL" >"$STAGE_DIR/metadata/hysteria.url"
printf '%s\n' "$HYSTERIA_ARTIFACT_SHA256" >"$STAGE_DIR/metadata/hysteria.sha256" 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" cd "$STAGE_DIR"
+27
View File
@@ -54,6 +54,10 @@ verify_archive() {
printf '%s\n' "$listing" | grep -q '^hy2xs-install/config/hy2xs.env$' || fail "archive missing canonical runtime config" printf '%s\n' "$listing" | grep -q '^hy2xs-install/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/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/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 local license_text
license_text="$(tar -xOzf "$archive" hy2xs-install/LICENSE)" license_text="$(tar -xOzf "$archive" hy2xs-install/LICENSE)"
@@ -70,6 +74,29 @@ verify_archive() {
local hysteria_tpl local hysteria_tpl
hysteria_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/hysteria/config.yaml.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 '/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 local post_install_tpl
post_install_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/env/post-install.env.tpl)" post_install_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/env/post-install.env.tpl)"
+527
View File
@@ -0,0 +1,527 @@
#!/usr/bin/env bash
set -euo pipefail
# HY2XS end-to-end проверка связки «сервер + реальный клиент Hysteria».
#
# Что проверяется:
# 1. сервер принимает сгенерированный HY2XS конфиг и стартует;
# 2. TLS handshake;
# 3. handshake с обфускацией (gecko и salamander);
# 4. HTTP auth HY2XS: допуск разрешённого пира и отказ неразрешённому;
# 5. клиент подключается ИМЕННО по сгенерированному hysteria2:// URI;
# 6. TCP forwarding через туннель;
# 7. UDP forwarding через туннель;
# 8. trafficStats API: валидный и невалидный secret;
# 9. per-peer accounting;
# 10. перезапуск сервера и быстрое переподключение клиента.
#
# Скрипт самодостаточен: поднимает mock HY2XS auth endpoint, поэтому не требует
# установленной админки и базы. Рассчитан на Debian 13 amd64.
#
# ./tools/test/e2e-hysteria.sh
# HYSTERIA_BIN=/tmp/hysteria ./tools/test/e2e-hysteria.sh --obfs salamander
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
OBFS_TYPES=()
HYSTERIA_BIN="${HYSTERIA_BIN:-/usr/local/bin/hysteria}"
BUN_BIN="${BUN_BIN:-bun}"
SERVER_PORT="${E2E_SERVER_PORT:-34643}"
STATS_PORT="${E2E_STATS_PORT:-34644}"
AUTH_PORT="${E2E_AUTH_PORT:-34645}"
ORIGIN_TCP_PORT="${E2E_ORIGIN_TCP_PORT:-34646}"
ORIGIN_UDP_PORT="${E2E_ORIGIN_UDP_PORT:-34647}"
FORWARD_TCP_PORT="${E2E_FORWARD_TCP_PORT:-34648}"
FORWARD_UDP_PORT="${E2E_FORWARD_UDP_PORT:-34649}"
VALID_SECRET="hy2xs-e2e-valid-peer-secret"
INVALID_SECRET="hy2xs-e2e-invalid-peer-secret"
STATS_SECRET="hy2xs-e2e-traffic-secret"
OBFS_PASSWORD="hy2xs-e2e-obfs-password"
SNI_NAME="hy2xs-e2e.local"
WORK_DIR=""
SERVER_PID=""
CLIENT_PID=""
AUTH_PID=""
ORIGIN_PID=""
log() { printf '[hy2xs-e2e] %s\n' "$*"; }
step() { printf '\n[hy2xs-e2e] ==> %s\n' "$*"; }
pass() { printf '[hy2xs-e2e] ok: %s\n' "$*"; }
die() { printf '[hy2xs-e2e] FAIL: %s\n' "$*" >&2; exit 1; }
usage() {
cat >&2 <<'EOF'
Usage: tools/test/e2e-hysteria.sh [--obfs gecko|salamander|all]
Environment:
HYSTERIA_BIN path to the Hysteria binary under test (default /usr/local/bin/hysteria)
BUN_BIN path to bun (default: bun from PATH)
EOF
exit 2
}
parse_args() {
local requested="all"
while [ $# -gt 0 ]; do
case "$1" in
--obfs)
requested="${2:-}"
shift 2 || true
;;
-h|--help) usage ;;
*) die "unknown argument: $1" ;;
esac
done
case "$requested" in
all) OBFS_TYPES=(gecko salamander) ;;
gecko) OBFS_TYPES=(gecko) ;;
salamander) OBFS_TYPES=(salamander) ;;
*) die "unsupported --obfs value: $requested" ;;
esac
}
require_tool() {
command -v "$1" >/dev/null 2>&1 || die "required tool not found: $1"
}
preflight() {
[ -x "$HYSTERIA_BIN" ] || die "Hysteria binary not found or not executable: $HYSTERIA_BIN (set HYSTERIA_BIN)"
require_tool openssl
require_tool curl
require_tool ss
command -v "$BUN_BIN" >/dev/null 2>&1 || die "bun not found (set BUN_BIN)"
local port
for port in "$SERVER_PORT" "$STATS_PORT" "$AUTH_PORT" \
"$ORIGIN_TCP_PORT" "$ORIGIN_UDP_PORT" "$FORWARD_TCP_PORT" "$FORWARD_UDP_PORT"; do
if ss -H -lantu 2>/dev/null | awk '{print $5}' | grep -Eq "[:.]${port}\$"; then
die "port $port is already in use"
fi
done
log "Hysteria under test: $("$HYSTERIA_BIN" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
}
kill_pid() {
local name="$1"
local pid="${!name}"
[ -n "$pid" ] || return 0
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
printf -v "$name" '%s' ""
}
cleanup() {
kill_pid CLIENT_PID
kill_pid SERVER_PID
kill_pid ORIGIN_PID
kill_pid AUTH_PID
[ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR" || true
}
trap cleanup EXIT
wait_for_port() {
local port="$1" proto_flag="$2" label="$3" attempts="${4:-30}"
local i=0
while [ "$i" -lt "$attempts" ]; do
if ss -H "$proto_flag" 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}\$"; then
return 0
fi
sleep 1
i=$((i + 1))
done
die "$label did not start listening on port $port"
}
# Mock HY2XS auth endpoint повторяет контракт /hui/hysteria2/auth:
# проверку machine access_token и отказ неразрешённому секрету.
start_auth_endpoint() {
cat >"$WORK_DIR/auth-server.ts" <<EOF
const validSecret = ${VALID_SECRET@Q};
const machineToken = ${STATS_SECRET@Q};
Bun.serve({
hostname: "127.0.0.1",
port: ${AUTH_PORT},
async fetch(request) {
const url = new URL(request.url);
if (url.searchParams.get("access_token") !== machineToken) {
return new Response("forbidden", { status: 403 });
}
const body = await request.json().catch(() => null);
if (!body || typeof body.auth !== "string" || typeof body.tx !== "number") {
return Response.json({ ok: false }, { status: 400 });
}
if (body.auth !== validSecret) {
return Response.json({ ok: false });
}
return Response.json({ ok: true, id: "e2e-peer" });
}
});
EOF
"$BUN_BIN" run "$WORK_DIR/auth-server.ts" >"$WORK_DIR/auth.log" 2>&1 &
AUTH_PID=$!
wait_for_port "$AUTH_PORT" "-ltn" "mock auth endpoint"
pass "mock HY2XS auth endpoint is up"
}
# TCP- и UDP-origin: отвечают фиксированной строкой, чтобы можно было
# однозначно отличить рабочий туннель от неответа.
start_origins() {
cat >"$WORK_DIR/origins.ts" <<EOF
Bun.listen({
hostname: "127.0.0.1",
port: ${ORIGIN_TCP_PORT},
socket: {
open(socket) {
socket.write("HY2XS-TCP-OK\n");
socket.flush();
socket.end();
},
data() {},
error() {}
}
});
await Bun.udpSocket({
hostname: "127.0.0.1",
port: ${ORIGIN_UDP_PORT},
socket: {
data(socket, _buf, port, address) {
socket.send("HY2XS-UDP-OK", port, address);
}
}
});
EOF
"$BUN_BIN" run "$WORK_DIR/origins.ts" >"$WORK_DIR/origins.log" 2>&1 &
ORIGIN_PID=$!
wait_for_port "$ORIGIN_TCP_PORT" "-ltn" "tcp origin"
wait_for_port "$ORIGIN_UDP_PORT" "-lun" "udp origin"
pass "TCP and UDP origins are up"
}
render_server_config() {
local obfs_type="$1"
(
cd orchestrator
"$BUN_BIN" run tools/render-canonical-config.ts \
--package-dir ../package \
--obfs "$obfs_type" \
--tls-mode file \
--cert "$WORK_DIR/server.crt" \
--key "$WORK_DIR/server.key" \
--port "$SERVER_PORT" \
--traffic-stats-port "$STATS_PORT" \
--out "$WORK_DIR/server.yaml"
) || die "could not render canonical server config for obfs=$obfs_type"
# Подставляем e2e-секреты и локальный auth endpoint, не трогая структуру
# конфига: проверяется тот же YAML, который получает production.
"$BUN_BIN" -e '
const [path, obfs, stats, authPort] = process.argv.slice(1);
let text = require("node:fs").readFileSync(path, "utf8");
text = text.split("hy2xs-compat-gate-obfs-password").join(obfs);
text = text.split("hy2xs-compat-gate-traffic-secret").join(stats);
text = text.replace(/url: http:\/\/127\.0\.0\.1:\d+\//, `url: http://127.0.0.1:${authPort}/`);
require("node:fs").writeFileSync(path, text);
' "$WORK_DIR/server.yaml" "$OBFS_PASSWORD" "$STATS_SECRET" "$AUTH_PORT" \
|| die "could not inject e2e secrets into the server config"
grep -q "type: $obfs_type" "$WORK_DIR/server.yaml" \
|| die "rendered server config does not use obfs type $obfs_type"
grep -q "127.0.0.1:${AUTH_PORT}/hui/hysteria2/auth?access_token=" "$WORK_DIR/server.yaml" \
|| die "server config lost the HY2XS machine auth token"
}
start_server() {
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" server -c "$WORK_DIR/server.yaml" \
>>"$WORK_DIR/server.log" 2>&1 &
SERVER_PID=$!
local i=0
while [ "$i" -lt 20 ]; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
log "--- server log ---"
cat "$WORK_DIR/server.log" >&2 || true
die "server exited while starting"
fi
if ss -H -lun 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${SERVER_PORT}\$"; then
return 0
fi
sleep 1
i=$((i + 1))
done
log "--- server log ---"
cat "$WORK_DIR/server.log" >&2 || true
die "server did not start listening on UDP $SERVER_PORT"
}
urlencode() {
"$BUN_BIN" -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1"
}
# Ссылка строится ровно по официальной URI-схеме, как её генерирует админка.
build_share_uri() {
local obfs_type="$1"
local secret="$2"
printf 'hysteria2://%s@127.0.0.1:%s/?obfs=%s&obfs-password=%s&sni=%s&insecure=1' \
"$(urlencode "$secret")" "$SERVER_PORT" "$obfs_type" "$(urlencode "$OBFS_PASSWORD")" "$SNI_NAME"
}
# Клиент конфигурируется ИЗ share URI, а не собирается независимо: именно это
# ловит расхождение между генератором ссылок и реальным сервером.
write_client_config_from_uri() {
cat >"$WORK_DIR/client.yaml" <<EOF
server: $1
tcpForwarding:
- listen: 127.0.0.1:${FORWARD_TCP_PORT}
remote: 127.0.0.1:${ORIGIN_TCP_PORT}
udpForwarding:
- listen: 127.0.0.1:${FORWARD_UDP_PORT}
remote: 127.0.0.1:${ORIGIN_UDP_PORT}
timeout: 20s
EOF
}
start_client() {
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" client -c "$WORK_DIR/client.yaml" \
>>"$WORK_DIR/client.log" 2>&1 &
CLIENT_PID=$!
local i=0
while [ "$i" -lt 25 ]; do
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
log "--- client log ---"
cat "$WORK_DIR/client.log" >&2 || true
die "client exited while connecting via the generated share URI"
fi
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${FORWARD_TCP_PORT}\$"; then
return 0
fi
sleep 1
i=$((i + 1))
done
log "--- client log ---"
cat "$WORK_DIR/client.log" >&2 || true
die "client did not open its forwarding listeners"
}
probe_tcp() {
"$BUN_BIN" -e '
const port = Number(process.argv[1]);
const chunks: string[] = [];
const socket = await Bun.connect({
hostname: "127.0.0.1",
port,
socket: {
data(_s, chunk) { chunks.push(new TextDecoder().decode(chunk)); },
error() {}
}
});
await Bun.sleep(Number(process.argv[2]));
socket.end();
process.stdout.write(chunks.join(""));
' "$FORWARD_TCP_PORT" "3000" 2>/dev/null || true
}
probe_udp() {
"$BUN_BIN" -e '
const port = Number(process.argv[1]);
const answer = await new Promise<string>(async (resolve) => {
const timer = setTimeout(() => resolve(""), Number(process.argv[2]));
const socket = await Bun.udpSocket({
hostname: "127.0.0.1",
port: 0,
socket: {
data(_s, buf) {
clearTimeout(timer);
resolve(new TextDecoder().decode(buf));
}
}
});
socket.send("ping", port, "127.0.0.1");
});
process.stdout.write(answer);
' "$FORWARD_UDP_PORT" "5000" 2>/dev/null || true
}
assert_tcp_forwarding() {
local i=0
while [ "$i" -lt 10 ]; do
case "$(probe_tcp)" in
*HY2XS-TCP-OK*)
pass "TCP forwarding works through the tunnel"
return 0
;;
esac
sleep 1
i=$((i + 1))
done
die "TCP forwarding through the tunnel failed"
}
assert_udp_forwarding() {
local i=0
while [ "$i" -lt 10 ]; do
case "$(probe_udp)" in
*HY2XS-UDP-OK*)
pass "UDP forwarding works through the tunnel"
return 0
;;
esac
sleep 1
i=$((i + 1))
done
die "UDP forwarding through the tunnel failed"
}
assert_traffic_stats() {
local code
code="$(curl --silent --max-time 5 -o "$WORK_DIR/online.json" -w '%{http_code}' \
-H "Authorization: ${STATS_SECRET}" "http://127.0.0.1:${STATS_PORT}/online" || true)"
[ "$code" = "200" ] || die "trafficStats /online returned $code for a valid secret"
local denied
denied="$(curl --silent --max-time 5 -o /dev/null -w '%{http_code}' \
-H "Authorization: definitely-not-the-secret" "http://127.0.0.1:${STATS_PORT}/online" || true)"
case "$denied" in
401|403) ;;
*) die "trafficStats /online returned $denied for an invalid secret, expected 401/403" ;;
esac
local traffic_code
traffic_code="$(curl --silent --max-time 5 -o "$WORK_DIR/traffic.json" -w '%{http_code}' \
-H "Authorization: ${STATS_SECRET}" "http://127.0.0.1:${STATS_PORT}/traffic" || true)"
[ "$traffic_code" = "200" ] || die "trafficStats /traffic returned $traffic_code"
grep -q 'e2e-peer' "$WORK_DIR/traffic.json" \
|| die "per-peer accounting does not contain the authenticated peer: $(cat "$WORK_DIR/traffic.json")"
pass "trafficStats API and per-peer accounting work"
}
assert_reconnect_after_restart() {
kill_pid SERVER_PID
start_server
local i=0
while [ "$i" -lt 25 ]; do
case "$(probe_tcp)" in
*HY2XS-TCP-OK*)
pass "client recovered the tunnel after a server restart (${i}s)"
return 0
;;
esac
sleep 1
i=$((i + 1))
done
die "client did not recover the tunnel after a server restart"
}
assert_auth_rejects_unknown_peer() {
local obfs_type="$1"
kill_pid CLIENT_PID
write_client_config_from_uri "$(build_share_uri "$obfs_type" "$INVALID_SECRET")"
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" client -c "$WORK_DIR/client.yaml" \
>"$WORK_DIR/client-denied.log" 2>&1 &
CLIENT_PID=$!
# Клиент либо завершается, либо не может пробросить трафик.
local i=0
while [ "$i" -lt 15 ]; do
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
kill_pid CLIENT_PID
pass "unauthorized peer is rejected by HY2XS HTTP auth (client exited)"
return 0
fi
if grep -Eqi 'auth|denied|reject|unauthor' "$WORK_DIR/client-denied.log"; then
kill_pid CLIENT_PID
pass "unauthorized peer is rejected by HY2XS HTTP auth (auth failure reported)"
return 0
fi
sleep 1
i=$((i + 1))
done
local leaked
leaked="$(probe_tcp)"
kill_pid CLIENT_PID
case "$leaked" in
*HY2XS-TCP-OK*)
die "server accepted a peer that HY2XS auth must reject"
;;
esac
pass "unauthorized peer cannot pass traffic"
}
run_profile() {
local obfs_type="$1"
step "profile: obfs=$obfs_type"
: >"$WORK_DIR/server.log"
: >"$WORK_DIR/client.log"
render_server_config "$obfs_type"
start_server
pass "server accepted the HY2XS config and started (TLS + $obfs_type)"
local uri
uri="$(build_share_uri "$obfs_type" "$VALID_SECRET")"
log "share URI under test: ${uri//$OBFS_PASSWORD/<obfs-password>}"
write_client_config_from_uri "$uri"
start_client
pass "client connected using the generated hysteria2:// URI (TLS + $obfs_type handshake)"
assert_tcp_forwarding
assert_udp_forwarding
assert_traffic_stats
assert_reconnect_after_restart
assert_auth_rejects_unknown_peer "$obfs_type"
kill_pid CLIENT_PID
kill_pid SERVER_PID
pass "profile obfs=$obfs_type passed"
}
main() {
parse_args "$@"
preflight
WORK_DIR="$(mktemp -d)"
log "work dir: $WORK_DIR"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj "/CN=${SNI_NAME}" \
-addext "subjectAltName=DNS:${SNI_NAME}" \
-keyout "$WORK_DIR/server.key" -out "$WORK_DIR/server.crt" >/dev/null 2>&1 \
|| die "could not generate the e2e certificate"
start_auth_endpoint
start_origins
local obfs_type
for obfs_type in "${OBFS_TYPES[@]}"; do
run_profile "$obfs_type"
done
step "all HY2XS end-to-end checks passed"
}
main "$@"