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="#конфигурация-hy2xsenv">Конфигурация</a> ·
<a href="#версионная-политика-hysteria2">Версии</a> ·
<a href="#сборка-release-пакета">Сборка</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
<a href="#лицензия">Лицензия</a>
</p>
@@ -39,7 +41,7 @@ HY2XS подходит для сценария, где нужен один produ
HY2XS release‑пакет разворачивает и настраивает:
- официальный upstream‑бинарник Hysteria2, закреплённый в metadata пакета и проверяемый по SHA256;
- официальный upstream‑бинарник Hysteria2: последняя стабильная версия выбирается при сборке пакета, закрепляется в его metadata и проверяется по SHA256;
- HY2XS admin — встроенную админ‑панель для управления users/peers, трафиком, конфигурацией, логами и состоянием сервера;
- systemd‑юнит `hysteria-server` для Hysteria2;
- systemd‑юнит `hy2xs-admin` для админ‑панели;
@@ -108,6 +110,8 @@ Windows и macOS можно использовать для разработки
- не включает IPv6production baseline;
- не настраивает `sshd` автоматически;
- не предоставляет полноценный uninstall/update framework;
- не обновляет Hysteria2 на уже работающем сервере: `reconfigure` намеренно не является Hysteria updater;
- не мигрирует установки `0.x` на `1.0.0` — переход выполняется чистой установкой, см. [CHANGELOG](CHANGELOG.md);
- не выполняет сложную миграцию старых неизвестных состояний сервера;
- не реализует Telegram‑бота, port hopping и универсальный accessdelivery workflow;
- не предназначен для установки поверх давно используемого сервера с неизвестными firewall/systemd‑правками.
@@ -147,10 +151,74 @@ hy2xs-install/
| TrafficStats Hysteria2 | `127.0.0.1:36712` |
| Firewall mode | `takeover` в packaged baseline |
| Hysteria2 auth | `http` через локальный HY2XS admin |
| Hysteria2 obfs | `salamander` |
| Hysteria2 obfs | `gecko` (512/1200); `salamander` доступен как режим совместимости |
| Congestion fallback | `bbr`, профиль `standard` |
| QUIC stateless reset | включён |
Важно: `HY2XS_SSH_PORT` нужен HY2XS для nftables‑правил и проверки доступности SSH‑порта. Сам `sshd` проект не перенастраивает. SSH на `2323` и вход только по ключу нужно настроить до запуска `./install.sh`.
## Версионная политика Hysteria2
HY2XS **не привязан к конкретному номеру версии Hysteria**.
> Источник по умолчанию берёт последний стабильный релиз Hysteria, доступный на момент сборки пакета. Разрешённая версия, URL артефакта и контрольная сумма замораживаются в получившемся install‑пакете.
Как это работает:
```text
build machine target server
───────────── ─────────────
определить последнюю стабильную ─┐
скачать артефакт, посчитать SHA-256 │
проверить, что бинарник принимает ├─► release‑пакет ──► скачать ровно
канонический конфиг HY2XS │ version + url этот артефакт,
заморозить version/url/sha256 ─┘ + sha256 сверить SHA-256
и `hysteria version`
```
Что это даёт:
- новая установка получает актуальную Hysteria без ручного обновления version lock;
- если между сборкой пакета и его установкой выйдет новая версия, **содержимое установки не изменится**;
- повторная установка старого пакета поставит ту же версию, что и в день сборки;
- несовместимый upstream ломает сборку, а не сервер оператора.
Переопределения при сборке:
```bash
# по умолчанию: последняя стабильная
./tools/build/build.sh
# закрепить конкретную версию
HYSTERIA_VERSION_OVERRIDE=v2.12.2 ./tools/build/build.sh
# офлайн-сборка по закоммиченному tools/build/hysteria-lock.env
HYSTERIA_CHANNEL=pinned ./tools/build/build.sh
```
Фактически установленная версия видна в `/etc/hysteria/post-install.env` (`HY2_VERSION`), а способ её выбора — в `HY2_RESOLUTION`.
Обновление Hysteria на уже работающем сервере в текущем релизе не поддерживается: `reconfigure` намеренно не является Hysteria updater. Это сохраняет immutable‑контракт развёртывания.
## Обфускация
Новые установки HY2XS используют **Gecko**.
Gecko помечен upstream как **experimental**. Он достраивается поверх Salamander: помимо scramble он дополнительно фрагментирует QUIC handshake на пакеты случайного размера. HY2XS использует upstream‑defaults размеров пакетов `512/1200` как проверенный production‑профиль.
**Salamander остаётся поддержанным режимом совместимости.** Смена типа обфускации требует соответствующих изменений на клиенте: это изменение wire‑совместимости, а не косметическая настройка.
| | Gecko | Salamander |
| --- | --- | --- |
| Статус upstream | experimental | stable |
| Роль в HY2XS | default для новых установок | режим совместимости |
| Параметр | `HY2XS_HYSTERIA_OBFS_TYPE=gecko` | `HY2XS_HYSTERIA_OBFS_TYPE=salamander` |
| В клиентской ссылке | `obfs=gecko` | `obfs=salamander` |
Экспериментальность upstream остаётся контролируемым риском, потому что одновременно выполняются три условия: Salamander доступен как fallback, каждая разрешённая версия проходит compatibility gate до выпуска пакета, и существующие серверы никогда не переводятся на Gecko молча.
Размеры пакетов Gecko не выносятся в конфигурацию: официальная схема `hysteria2://` не умеет их передавать, поэтому нестандартные значения сделали бы клиентскую ссылку неполной.
## Быстрый старт для нового сервера
Ниже приведён полный путь для оператора, который работает с Windows и ставит HY2XS на чистый Debian 13 сервер.
@@ -379,10 +447,12 @@ HY2XS_IPV6_ENABLED=false
HY2XS_TLS_MODE=acme
HY2XS_ACME_TYPE=http
HY2XS_HYSTERIA_AUTH_MODE=http
HY2XS_HYSTERIA_OBFS_TYPE=salamander
HY2XS_HYSTERIA_OBFS_TYPE=gecko
HY2XS_UI_PUBLIC_ACCESS=false
```
Если нужен режим совместимости со старыми клиентами, укажите `HY2XS_HYSTERIA_OBFS_TYPE=salamander`. Подробнее — в разделе [Обфускация](#обфускация).
### 9. Запустите установку
```bash
@@ -396,7 +466,7 @@ HY2XS_UI_PUBLIC_ACCESS=false
3. создаст runtime‑каталоги и service users;
4. запишет `/etc/hy2xs/hy2xs.env`;
5. разложит bundled HY2XS admin;
6. скачает pinned Hysteria2 binary из upstream и проверит SHA256;
6. скачает закреплённый в пакете Hysteria2 binary из upstream, проверит SHA256 и фактическую версию;
7. создаст `/etc/hysteria/config.yaml`;
8. установит systemd‑юниты;
9. применит nftables‑правила;
@@ -507,6 +577,7 @@ hy2xs-orchestrator status \
| Переменная | Назначение | Значение по умолчанию в packaged baseline |
| --- | --- | --- |
| `HY2XS_CONFIG_SCHEMA_VERSION` | Версия схемы конфигурации HY2XS. Конфигурация другой схемы отклоняется fail‑fast | `2` |
| `HY2XS_IPV6_ENABLED` | IPv6‑режим. В production baseline должен быть `false` | `false` |
| `HY2XS_DOMAIN` | Домен для ACME и deploy‑профиля | `fi.api.withen.pro` |
| `HY2XS_DNS_AAAA_POLICY` | Поведение при наличии AAAA‑записи: `strict`, `warn`, `off` | `strict` |
@@ -534,8 +605,8 @@ hy2xs-orchestrator status \
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API | `127.0.0.1` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT` | Порт trafficStats API | `36712` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` |
| `HY2XS_HYSTERIA_OBFS_TYPE` | Obfuscation type. Фиксированное значение production‑профиля | `salamander` |
| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Salamander password | `__GENERATE__` |
| `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` |
| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Пароль обфускации; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_HYSTERIA_BANDWIDTH_UP` | Hysteria2 upstream bandwidth | `50 mbps` |
| `HY2XS_HYSTERIA_BANDWIDTH_DOWN` | Hysteria2 downstream bandwidth | `50 mbps` |
| `HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH` | Игнорировать bandwidth клиента | `false` |
@@ -717,7 +788,7 @@ git status --short
Подготовьте build env:
```bash
export PACKAGE_VERSION=0.2.2
export PACKAGE_VERSION=1.0.0
export BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ)
# Для переносимости между x86_64-серверами без AVX2 предпочтителен baseline artifact.
@@ -728,6 +799,9 @@ export BUN_FLAVOR=x64-baseline
export GO_ARCHIVE_SHA256=<sha256-go1.21.13-linux-amd64.tar.gz>
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>
# Опционально: снимает 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
```
Сборка последовательно:
1. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
2. определяет последнюю стабильную версию Hysteria, скачивает артефакт и считает SHA‑256;
3. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
4. собирает orchestrator, frontend и backend;
5. прогоняет `go vet` и `go test` для HY2XS admin;
6. формирует архив и прогоняет acceptance‑проверки.
Любой сбой на шагах 1–5 останавливает сборку до создания пакета.
Переменные, управляющие выбором версии Hysteria:
| Переменная | По умолчанию | Назначение |
| --- | --- | --- |
| `HYSTERIA_CHANNEL` | `stable` | `stable` — разрешить последнюю стабильную; `pinned` — офлайн‑сборка по `tools/build/hysteria-lock.env` |
| `HYSTERIA_VERSION_OVERRIDE` | пусто | Закрепить конкретную версию `vX.Y.Z` |
| `HYSTERIA_COMPAT_GATE` | `true` | Compatibility gate; для release‑сборок обязателен |
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл |
Полный E2E с реальным клиентом Hysteria запускается отдельно:
```bash
HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
```
Результат:
```text
@@ -748,7 +848,7 @@ dist/hy2xs-install-<version>.tar.gz
ls -lh dist/hy2xs-install-*.tar.gz
sha256sum dist/hy2xs-install-*.tar.gz
tar -tzf dist/hy2xs-install-0.2.2.tar.gz | grep -E \
tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \
'^(hy2xs-install/install.sh|hy2xs-install/orchestrator/hy2xs-orchestrator|hy2xs-install/ui/hy2xs-admin/hy2xs-admin|hy2xs-install/metadata/checksums.txt)$'
```
@@ -762,6 +862,9 @@ tar -tzf dist/hy2xs-install-0.2.2.tar.gz | grep -E \
├── orchestrator/ # install-only orchestrator на Bun + TypeScript
├── package/ # skeleton будущего install package
├── tools/build/ # production builder и packaging pipeline
├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria
├── docs/ # спецификации baseline, тестов и эксплуатации
├── CHANGELOG.md
├── README.md
└── LICENSE
```
+7 -58
View File
@@ -4,9 +4,6 @@ import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
@@ -183,72 +180,24 @@ func UpdateHysteria2Config(c *gin.Context) {
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
}
// ExportHysteria2Config отдаёт оператору фактический серверный конфиг.
//
// Экспорт работает от исходного YAML, а не от типизированной модели: поля,
// о которых HY2XS ещё не знает, обязаны пережить выгрузку. Секреты при этом
// вырезаются — файл покидает сервер.
func ExportHysteria2Config(c *gin.Context) {
hysteria2ServerConfig, err := service.GetHysteria2Config()
sanitized, err := service.ExportHysteria2ConfigYaml()
if err != nil {
vo.Fail(err.Error(), c)
return
}
// Значения по умолчанию
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.Hysteria2TrafficStatsSecret})
if err != nil {
vo.Fail(err.Error(), c)
return
}
var hUIWebPort string
var trafficStatsSecret string
for _, item := range config {
if *item.Key == constant.HUIWebPort {
hUIWebPort = *item.Value
} else if *item.Key == constant.Hysteria2TrafficStatsSecret {
trafficStatsSecret = *item.Value
}
}
if hUIWebPort == "" || trafficStatsSecret == "" {
logrus.Errorf("hUIWebPort or trafficStatsSecret is nil")
vo.Fail(constant.SysError, c)
return
}
authHttpUrl, err := service.GetAuthHttpUrl()
if err != nil {
vo.Fail(err.Error(), c)
return
}
authType := "http"
authHttpInsecure := true
var auth bo.ServerConfigAuth
auth.Type = &authType
var http bo.ServerConfigAuthHTTP
http.URL = &authHttpUrl
http.Insecure = &authHttpInsecure
auth.HTTP = &http
hysteria2ServerConfig.Auth = &auth
if hysteria2ServerConfig.TrafficStats == nil {
hysteria2ServerConfig.TrafficStats = &bo.ServerConfigTrafficStats{}
}
hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405"))
filePath := filepath.Join(constant.ExportPathDir, fileName)
if err = util.ExportFile(filePath, hysteria2ServerConfig, 1); err != nil {
vo.Fail(err.Error(), c)
return
}
if !util.Exists(filePath) {
vo.Fail("file not exist", c)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
c.Data(200, "application/octet-stream", sanitized)
}
func ImportHysteria2Config(c *gin.Context) {
+54 -9
View File
@@ -26,6 +26,10 @@ export interface Hysteria2ServerConfig {
cert: string;
key: string;
sniGuard?: string;
clientCA?: string;
};
ech?: {
keyPath?: string;
};
acme?: {
domains: string[];
@@ -51,9 +55,14 @@ export interface Hysteria2ServerConfig {
};
obfs?: {
type: string;
salamander: {
salamander?: {
password: string;
};
gecko?: {
password: string;
minPacketSize?: number;
maxPacketSize?: number;
};
};
quic?: {
initStreamReceiveWindow?: number;
@@ -63,10 +72,16 @@ export interface Hysteria2ServerConfig {
maxIdleTimeout?: string;
maxIncomingStreams?: number;
disablePathMTUDiscovery?: boolean;
disableStatelessReset?: boolean;
};
bandwidth?: {
up: string;
down: string;
disableLossCompensation?: boolean;
};
congestion?: {
type?: string;
bbrProfile?: string;
};
ignoreClientBandwidth?: boolean;
speedTest?: boolean;
@@ -122,6 +137,7 @@ export interface Hysteria2ServerConfig {
url: string;
rewriteHost: boolean;
insecure: boolean;
xForwarded?: boolean;
};
string?: {
content: string;
@@ -132,6 +148,26 @@ export interface Hysteria2ServerConfig {
listenHTTPS?: string;
forceHTTPS?: boolean;
};
mimic?: {
enabled?: boolean;
interface?: string;
xdpMode?: string;
path?: string;
extraArgs?: string[];
};
realm?: {
stunServers?: string[];
stunTimeout?: string;
punchTimeout?: string;
heartbeatInterval?: string;
insecure?: boolean;
ipMode?: string;
portMapping?: {
enabled?: boolean;
timeout?: string;
lifetime?: string;
};
};
}
export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
@@ -144,9 +180,9 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
acme: {
domains: [],
email: "",
ca: "zerossl",
ca: "letsencrypt",
listenHost: "0.0.0.0",
dir: "my_acme_dir",
dir: "/var/lib/hysteria/acme",
type: "",
http: {
altPort: 8888,
@@ -155,7 +191,7 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
altPort: 44333,
},
dns: {
name: "gomommy",
name: "cloudflare",
config: {},
},
disableHTTP: false,
@@ -164,9 +200,11 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
altTLSALPNPort: 443,
},
obfs: {
type: "salamander",
salamander: {
password: "cry_me_a_r1ver",
type: "gecko",
gecko: {
password: "",
minPacketSize: 512,
maxPacketSize: 1200,
},
},
quic: {
@@ -177,10 +215,16 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
maxIdleTimeout: "30s",
maxIncomingStreams: 1024,
disablePathMTUDiscovery: false,
disableStatelessReset: false,
},
bandwidth: {
up: "1 gbps",
down: "1 gbps",
up: "50 mbps",
down: "50 mbps",
disableLossCompensation: false,
},
congestion: {
type: "bbr",
bbrProfile: "standard",
},
ignoreClientBandwidth: false,
speedTest: false,
@@ -236,6 +280,7 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
url: "",
rewriteHost: true,
insecure: false,
xForwarded: false,
},
string: {
content: "hello stupid world",
+28 -1
View File
@@ -128,6 +128,17 @@ export default {
name: "Peer",
remark: "Remark",
secret: "Secret",
form: {
namePlaceholder: "e.g. ivan-laptop",
nameHint:
"Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret.",
remarkPlaceholder: "e.g. Ivan's laptop, sales team",
remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate automatically",
secretHint:
"Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.",
quotaHint: "Traffic limit in bytes. Use -1 for unlimited.",
},
maxDevices: "Max devices",
disabled: "Disabled",
status: "Status",
@@ -234,6 +245,7 @@ export default {
obfs: "Obfuscation",
quic: "QUIC parameters",
bandwidth: "Bandwidth",
congestion: "Congestion control",
speedTest: "Speed Test",
udp: "UDP",
resolver: "Resolver",
@@ -282,10 +294,17 @@ export default {
"Alternate TLS-ALPN challenge port. (Note: If you want to use anything other than 443, you must set up port forward/SNI proxy from 443 to that port, otherwise ACME will not be able to issue the certificate.)",
},
obfs: {
type: "Type",
type: "Obfuscation type: gecko (HY2XS production default) or salamander (compatibility fallback).",
salamander: {
password: "Replace with a strong password of your choice.",
},
gecko: {
password: "Replace with a strong password of your choice.",
minPacketSize:
"Minimum QUIC handshake fragment size. HY2XS baseline: 512.",
maxPacketSize:
"Maximum QUIC handshake fragment size. HY2XS baseline: 1200, upstream limit: 2048.",
},
},
quic: {
initStreamReceiveWindow: "The initial QUIC stream receive window size.",
@@ -299,10 +318,18 @@ export default {
maxIncomingStreams:
"The maximum number of concurrent incoming streams.",
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.",
disableStatelessReset:
"Disable QUIC stateless reset. Kept off in the HY2XS baseline: stateless reset lets a client with a stale connection reconnect immediately after a server restart or device sleep.",
},
bandwidth: {
up: "Up",
down: "Down",
disableLossCompensation:
"Disable loss compensation. Kept off in the HY2XS baseline, so compensation stays active.",
},
congestion: {
type: "Fallback congestion controller: bbr or reno. Used when Brutal bandwidth is not negotiated by both sides.",
bbrProfile: "BBR profile: standard, conservative or aggressive.",
},
ignoreClientBandwidth:
"When enabled, makes the server to disregard any bandwidth hints set by clients",
+32 -2
View File
@@ -124,6 +124,17 @@ export default {
name: "Пир",
remark: "Комментарий",
secret: "Секрет",
form: {
namePlaceholder: "например, ivan-laptop",
nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет.",
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
secretHint:
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
},
maxDevices: "Лимит устройств",
disabled: "Отключён",
status: "Статус",
@@ -230,6 +241,7 @@ export default {
obfs: "Маскировка",
quic: "Параметры QUIC",
bandwidth: "Полоса",
congestion: "Congestion control",
speedTest: "Тест скорости",
udp: "UDP",
resolver: "DNS",
@@ -264,8 +276,15 @@ export default {
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
},
obfs: {
type: "Тип",
type: "Тип обфускации: gecko (production default HY2XS) или salamander (совместимость)",
salamander: { password: "Сильный пароль Salamander" },
gecko: {
password: "Сильный пароль Gecko",
minPacketSize:
"Минимальный размер фрагмента QUIC handshake. Baseline HY2XS: 512",
maxPacketSize:
"Максимальный размер фрагмента QUIC handshake. Baseline HY2XS: 1200, верхний предел upstream: 2048",
},
},
quic: {
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
@@ -275,8 +294,19 @@ export default {
maxIdleTimeout: "Максимальный idle timeout",
maxIncomingStreams: "Максимум входящих stream",
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
disableStatelessReset:
"Отключить QUIC stateless reset. В baseline HY2XS выключено: stateless reset ускоряет переподключение клиента после перезапуска сервера или сна устройства",
},
bandwidth: {
up: "Вверх",
down: "Вниз",
disableLossCompensation:
"Отключить компенсацию потерь. В baseline HY2XS выключено, то есть компенсация работает",
},
congestion: {
type: "Fallback congestion controller: bbr или reno. Применяется, когда Brutal bandwidth не согласован сторонами",
bbrProfile: "Профиль BBR: standard, conservative или aggressive",
},
bandwidth: { up: "Вверх", down: "Вниз" },
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
speedTest: "Встроенный сервер теста скорости",
disableUDP: "Отключить UDP forwarding",
+150 -3
View File
@@ -361,8 +361,12 @@
</el-select>
</el-form-item>
</el-tooltip>
<template
v-if="
dataForm.obfs.type === 'salamander' && dataForm.obfs.salamander
"
>
<el-tooltip
v-if="dataForm.obfs.type === 'salamander'"
:content="$t('hysteria.config.obfs.salamander.password')"
placement="bottom"
>
@@ -376,6 +380,50 @@
/>
</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 :label="$t('hysteria.quic')" name="quic" v-if="quic">
<el-tooltip
@@ -470,6 +518,17 @@
<el-switch v-model="dataForm.quic.disablePathMTUDiscovery" />
</el-form-item>
</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
:label="$t('hysteria.bandwidth')"
@@ -494,6 +553,19 @@
<el-input v-model="dataForm.bandwidth.down" clearable />
</el-form-item>
</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
:content="$t('hysteria.config.ignoreClientBandwidth')"
placement="bottom"
@@ -506,6 +578,53 @@
</el-form-item>
</el-tooltip>
</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
:label="$t('hysteria.speedTest')"
name="speedTest"
@@ -1020,15 +1139,22 @@ const tlsSniGuards = ref<string[]>(["dns-san", "disable", "strict"]);
const aclTypes = ref<string[]>(["file", "inline"]);
const acmeCas = ref<string[]>(["zerossl", "letsencrypt"]);
const acmeTypes = ref<string[]>(["http", "tls", "dns"]);
// Актуальный upstream-реестр ACME DNS-провайдеров.
// `namedotcom` удалён в Hysteria 2.11.0 вместе с переписанным ACME-стеком:
// конфигурация с ним больше не запускается.
const dnsNames = ref<string[]>([
"cloudflare",
"duckdns",
"gandi",
"godaddy",
"namedotcom",
"namecheap",
"njalla",
"porkbun",
"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 masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
@@ -1043,6 +1169,7 @@ const state = reactive({
obfs: false,
quic: false,
bandwidth: false,
congestion: false,
speedTest: false,
udp: false,
resolver: false,
@@ -1067,6 +1194,7 @@ const {
obfs,
quic,
bandwidth,
congestion,
speedTest,
udp,
resolver,
@@ -1120,6 +1248,7 @@ const setConfig = () => {
state.obfs = !!data?.obfs;
state.quic = !!data?.quic;
state.bandwidth = !!data?.bandwidth;
state.congestion = !!data?.congestion;
state.speedTest = !!data?.speedTest;
state.udp = !!data?.disableUDP || !!data?.udpIdleTimeout;
state.resolver = !!data?.resolver;
@@ -1130,10 +1259,28 @@ const setConfig = () => {
state.dataForm = deepCopy(defaultHysteria2ServerConfig);
assignWith(state.dataForm, data);
dropInactiveObfsSubtype(state.dataForm, data);
}
});
};
// Форма строится как merge дефолта и ответа API, поэтому неактивная ветка obfs
// осталась бы от дефолта и UI показал бы блок, которого нет в конфиге сервера.
const dropInactiveObfsSubtype = (
form: Hysteria2ServerConfig,
data: Hysteria2ServerConfig
) => {
if (!form.obfs) {
return;
}
if (form.obfs.type !== "gecko" || !data?.obfs?.gecko) {
delete form.obfs.gecko;
}
if (form.obfs.type !== "salamander" || !data?.obfs?.salamander) {
delete form.obfs.salamander;
}
};
const setHysteria2Monitor = async () => {
const { data } = await dashboardSummaryApi();
state.hysteria2Monitor.version = data.hysteria.version;
+39 -15
View File
@@ -65,9 +65,7 @@
<el-table-column :label="$t('peer.traffic')" min-width="260">
<template #default="scope">
<div>
{{
formatBytes(scope.row.downloadBytes + scope.row.uploadBytes)
}}
{{ formatBytes(scope.row.downloadBytes + scope.row.uploadBytes) }}
/ {{ quotaText(scope.row.quotaBytes) }}
</div>
<el-progress
@@ -167,18 +165,32 @@
:rules="rules"
label-width="140px"
>
<el-form-item :label="$t('peer.name')" prop="name"
><el-input v-model="dataForm.name"
/></el-form-item>
<el-form-item :label="$t('peer.remark')"
><el-input v-model="dataForm.remark"
/></el-form-item>
<el-form-item :label="$t('peer.secret')" prop="secret"
><el-input v-model="dataForm.secret" show-password
/></el-form-item>
<el-form-item :label="$t('peer.quota')"
><el-input-number v-model="dataForm.quotaBytes" :min="-1"
/></el-form-item>
<el-form-item :label="$t('peer.name')" prop="name">
<el-input
v-model="dataForm.name"
:placeholder="$t('peer.form.namePlaceholder')"
/>
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.remark')">
<el-input
v-model="dataForm.remark"
:placeholder="$t('peer.form.remarkPlaceholder')"
/>
<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-date-picker
v-model="dataForm.expiresAt"
@@ -573,23 +585,35 @@ onMounted(handleQuery);
.peer-title {
font-weight: 600;
}
.peer-sub {
font-size: 12px;
color: #909399;
}
.peer-actions {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
.peer-more-btn {
font-size: 16px;
}
.qr-dialog-body {
display: flex;
align-items: center;
justify-content: center;
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>
+114 -1
View File
@@ -1,12 +1,55 @@
package bo
// ObfsShareConfig — та часть обфускации, которую способна описать официальная
// схема hysteria2:// URI: только type и password.
//
// Gecko minPacketSize/maxPacketSize в ссылку не помещаются. Именно поэтому
// HY2XS фиксирует их на upstream defaults 512/1200: иначе сгенерированная
// ссылка не описывала бы подключение полностью.
type ObfsShareConfig struct {
Type string
Password string
}
// ObfsShare приводит любой поддерживаемый тип обфускации к единому виду для
// генерации клиентской ссылки. Знание о подтипах живёт рядом с моделью, чтобы
// добавление нового типа не требовало правок в слое share URI.
func (c *Hysteria2ServerConfig) ObfsShare() ObfsShareConfig {
if c == nil || c.Obfs == nil || c.Obfs.Type == nil {
return ObfsShareConfig{}
}
switch *c.Obfs.Type {
case "salamander":
if c.Obfs.Salamander != nil && c.Obfs.Salamander.Password != nil {
return ObfsShareConfig{Type: "salamander", Password: *c.Obfs.Salamander.Password}
}
case "gecko":
if c.Obfs.Gecko != nil && c.Obfs.Gecko.Password != nil {
return ObfsShareConfig{Type: "gecko", Password: *c.Obfs.Gecko.Password}
}
}
return ObfsShareConfig{}
}
// AcmeDomain возвращает первый ACME-домен, если он есть.
func (c *Hysteria2ServerConfig) AcmeDomain() string {
if c == nil || c.ACME == nil || len(c.ACME.Domains) == 0 {
return ""
}
return c.ACME.Domains[0]
}
type Hysteria2ServerConfig struct {
Listen *string `yaml:"listen,omitempty" json:"listen" validate:"required"`
Obfs *serverConfigObfs `yaml:"obfs,omitempty" json:"obfs" validate:"omitempty"`
TLS *serverConfigTLS `yaml:"tls,omitempty" json:"tls" validate:"omitempty"`
ACME *serverConfigACME `yaml:"acme,omitempty" json:"acme" validate:"omitempty"`
ECH *serverConfigECH `yaml:"ech,omitempty" json:"ech" validate:"omitempty"`
QUIC *serverConfigQUIC `yaml:"quic,omitempty" json:"quic" validate:"omitempty"`
Bandwidth *serverConfigBandwidth `yaml:"bandwidth,omitempty" json:"bandwidth" validate:"omitempty"`
Congestion *ServerConfigCongestion `yaml:"congestion,omitempty" json:"congestion" validate:"omitempty"`
IgnoreClientBandwidth *bool `yaml:"ignoreClientBandwidth,omitempty" json:"ignoreClientBandwidth" validate:"omitempty"`
SpeedTest *bool `yaml:"speedTest,omitempty" json:"speedTest" validate:"omitempty"`
DisableUDP *bool `yaml:"disableUDP,omitempty" json:"disableUDP" validate:"omitempty"`
@@ -18,21 +61,43 @@ type Hysteria2ServerConfig struct {
Outbounds []serverConfigOutboundEntry `yaml:"outbounds,omitempty" json:"outbounds" validate:"omitempty"`
TrafficStats *ServerConfigTrafficStats `yaml:"trafficStats,omitempty" json:"trafficStats" validate:"required"`
Masquerade *serverConfigMasquerade `yaml:"masquerade,omitempty" json:"masquerade" validate:"omitempty"`
Mimic *serverConfigMimic `yaml:"mimic,omitempty" json:"mimic" validate:"omitempty"`
Realm *serverConfigRealm `yaml:"realm,omitempty" json:"realm" validate:"omitempty"`
}
type serverConfigObfsSalamander struct {
Password *string `yaml:"password,omitempty" json:"password" validate:"required"`
}
// serverConfigObfsGecko описывает Gecko-обфускацию (Hysteria 2.9.2+).
// Gecko достраивается поверх Salamander и дополнительно фрагментирует
// QUIC handshake на пакеты случайного размера в диапазоне min..max.
type serverConfigObfsGecko struct {
Password *string `yaml:"password,omitempty" json:"password" validate:"required"`
MinPacketSize *int `yaml:"minPacketSize,omitempty" json:"minPacketSize" validate:"omitempty"`
MaxPacketSize *int `yaml:"maxPacketSize,omitempty" json:"maxPacketSize" validate:"omitempty"`
}
// Обе ветки опциональны: в конфиге присутствует ровно одна из них,
// соответствующая Type.
type serverConfigObfs struct {
Type *string `yaml:"type,omitempty" json:"type" validate:"required"`
Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"required"`
Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"omitempty"`
Gecko *serverConfigObfsGecko `yaml:"gecko,omitempty" json:"gecko" validate:"omitempty"`
}
type serverConfigTLS struct {
Cert *string `yaml:"cert,omitempty" json:"cert" validate:"required"`
Key *string `yaml:"key,omitempty" json:"key" validate:"required"`
SNIGuard *string `yaml:"sniGuard,omitempty" json:"sniGuard" validate:"omitempty"`
ClientCA *string `yaml:"clientCA,omitempty" json:"clientCA" validate:"omitempty"`
}
// serverConfigECH — Encrypted Client Hello (Hysteria 2.10.0+).
// HY2XS не генерирует ECH keypair и не включает ECH в default-профиле,
// но обязан корректно читать и сохранять этот блок.
type serverConfigECH struct {
KeyPath *string `yaml:"keyPath,omitempty" json:"keyPath" validate:"omitempty"`
}
type serverConfigACME struct {
@@ -78,11 +143,25 @@ type serverConfigQUIC struct {
MaxIdleTimeout *string `yaml:"maxIdleTimeout,omitempty" json:"maxIdleTimeout" validate:"omitempty"`
MaxIncomingStreams *int64 `yaml:"maxIncomingStreams,omitempty" json:"maxIncomingStreams" validate:"omitempty"`
DisablePathMTUDiscovery *bool `yaml:"disablePathMTUDiscovery,omitempty" json:"disablePathMTUDiscovery" validate:"omitempty"`
// DisableStatelessReset появился в Hysteria 2.12.2. HY2XS оставляет
// stateless reset включённым: клиент после перезапуска сервера или сна
// устройства переподключается сразу, а не по таймауту.
DisableStatelessReset *bool `yaml:"disableStatelessReset,omitempty" json:"disableStatelessReset" validate:"omitempty"`
}
type serverConfigBandwidth struct {
Up *string `yaml:"up,omitempty" json:"up" validate:"required"`
Down *string `yaml:"down,omitempty" json:"down" validate:"required"`
// DisableLossCompensation появился в Hysteria 2.10.0.
DisableLossCompensation *bool `yaml:"disableLossCompensation,omitempty" json:"disableLossCompensation" validate:"omitempty"`
}
// ServerConfigCongestion — fallback congestion controller. Используется, когда
// Brutal bandwidth не согласован сторонами. Это не то же самое, что host-level
// BBR ядра Linux: у Hysteria собственный congestion-control контур.
type ServerConfigCongestion struct {
Type *string `yaml:"type,omitempty" json:"type" validate:"omitempty"`
BBRProfile *string `yaml:"bbrProfile,omitempty" json:"bbrProfile" validate:"omitempty"`
}
type ServerConfigAuthHTTP struct {
@@ -183,9 +262,11 @@ type serverConfigMasqueradeFile struct {
}
type serverConfigMasqueradeProxy struct {
// URL с Hysteria 2.12.2 может указывать и на unix socket.
URL *string `yaml:"url,omitempty" json:"url" validate:"required"`
RewriteHost *bool `yaml:"rewriteHost,omitempty" json:"rewriteHost" validate:"required"`
Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"`
XForwarded *bool `yaml:"xForwarded,omitempty" json:"xForwarded" validate:"omitempty"`
}
type serverConfigMasqueradeString struct {
@@ -203,3 +284,35 @@ type serverConfigMasquerade struct {
ListenHTTPS *string `yaml:"listenHTTPS,omitempty" json:"listenHTTPS" validate:"omitempty"`
ForceHTTPS *bool `yaml:"forceHTTPS,omitempty" json:"forceHTTPS" validate:"omitempty"`
}
// serverConfigMimic — Mimic / fake TCP (Hysteria 2.12.0+).
// Требует отдельный сторонний бинарник, root и eBPF/XDP, поэтому в
// production-профиле HY2XS выключен: текущий systemd-контракт запускает
// Hysteria под непривилегированным пользователем.
type serverConfigMimic struct {
Enabled *bool `yaml:"enabled,omitempty" json:"enabled" validate:"omitempty"`
Interface *string `yaml:"interface,omitempty" json:"interface" validate:"omitempty"`
XDPMode *string `yaml:"xdpMode,omitempty" json:"xdpMode" validate:"omitempty"`
Path *string `yaml:"path,omitempty" json:"path" validate:"omitempty"`
ExtraArgs []string `yaml:"extraArgs,omitempty" json:"extraArgs" validate:"omitempty"`
}
type serverConfigRealmPortMapping struct {
Enabled *bool `yaml:"enabled,omitempty" json:"enabled" validate:"omitempty"`
Timeout *string `yaml:"timeout,omitempty" json:"timeout" validate:"omitempty"`
Lifetime *string `yaml:"lifetime,omitempty" json:"lifetime" validate:"omitempty"`
}
// serverConfigRealm — Hysteria Realms (2.9.0+), запуск сервера за NAT через
// STUN/hole punching. Меняет фундаментальный сетевой контракт HY2XS
// (выделенный сервер, публичный IPv4, UDP/443, own nftables), поэтому в
// default-профиле не используется, но должен корректно читаться.
type serverConfigRealm struct {
StunServers []string `yaml:"stunServers,omitempty" json:"stunServers" validate:"omitempty"`
StunTimeout *string `yaml:"stunTimeout,omitempty" json:"stunTimeout" validate:"omitempty"`
PunchTimeout *string `yaml:"punchTimeout,omitempty" json:"punchTimeout" validate:"omitempty"`
HeartbeatInterval *string `yaml:"heartbeatInterval,omitempty" json:"heartbeatInterval" validate:"omitempty"`
Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"omitempty"`
IPMode *string `yaml:"ipMode,omitempty" json:"ipMode" validate:"omitempty"`
PortMapping *serverConfigRealmPortMapping `yaml:"portMapping,omitempty" json:"portMapping" validate:"omitempty"`
}
+36 -19
View File
@@ -4,6 +4,7 @@ import (
"errors"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/proxy"
"net"
@@ -14,6 +15,28 @@ import (
"time"
)
// resolveShareSni выбирает SNI для клиентской ссылки.
//
// ACME-домен — не единственный источник истины: в режиме tls (файловые
// сертификаты) блока acme в конфиге нет, но домен продукта известен из
// runtime-конфига. Публичный IPv4 в качестве SNI не используется.
func resolveShareSni(acmeDomain string, publicHost string) string {
if domain := strings.TrimSpace(acmeDomain); domain != "" {
return domain
}
if domain := strings.TrimSpace(os.Getenv("HY2XS_DOMAIN")); domain != "" && !isIPAddress(domain) {
return domain
}
if host := strings.TrimSpace(publicHost); host != "" && !isIPAddress(host) {
return host
}
return ""
}
func isIPAddress(value string) bool {
return net.ParseIP(strings.TrimSpace(value)) != nil
}
func resolvePublicEndpoint() (string, int, error) {
host := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_HOST"))
if host == "" || host == "0.0.0.0" {
@@ -135,20 +158,8 @@ func Hysteria2Url(accountId int64) (string, error) {
remark = *hysteria2ConfigRemark.Value
}
obfsType := ""
obfsPassword := ""
if hysteria2Config.Obfs != nil &&
hysteria2Config.Obfs.Type != nil &&
hysteria2Config.Obfs.Salamander != nil &&
hysteria2Config.Obfs.Salamander.Password != nil {
obfsType = *hysteria2Config.Obfs.Type
obfsPassword = *hysteria2Config.Obfs.Salamander.Password
}
sni := ""
if hysteria2Config.ACME != nil && len(hysteria2Config.ACME.Domains) > 0 {
sni = hysteria2Config.ACME.Domains[0]
}
obfs := hysteria2Config.ObfsShare()
sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
secret := ""
if peer.SecretEncrypted != nil {
@@ -158,14 +169,20 @@ func Hysteria2Url(accountId int64) (string, error) {
}
secret = decrypted
}
return buildHysteria2Url(secret, hostname, port, obfsType, obfsPassword, sni, remark), nil
return buildHysteria2Url(secret, hostname, port, obfs, sni, remark), nil
}
func buildHysteria2Url(conPass string, hostname string, port int, obfsType string, obfsPassword string, sni string, remark string) string {
// isShareableObfsType перечисляет типы обфускации, которые официальная
// URI-схема Hysteria умеет передавать клиенту.
func isShareableObfsType(obfsType string) bool {
return obfsType == "salamander" || obfsType == "gecko"
}
func buildHysteria2Url(conPass string, hostname string, port int, obfs bo.ObfsShareConfig, sni string, remark string) string {
query := url.Values{}
if obfsType == "salamander" && obfsPassword != "" {
query.Set("obfs", "salamander")
query.Set("obfs-password", obfsPassword)
if isShareableObfsType(obfs.Type) && obfs.Password != "" {
query.Set("obfs", obfs.Type)
query.Set("obfs-password", obfs.Password)
}
if sni != "" {
query.Set("sni", sni)
+277 -30
View File
@@ -4,26 +4,115 @@ import (
"net/url"
"strings"
"testing"
"hy2xs-admin/model/bo"
"gopkg.in/yaml.v3"
)
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v (raw=%s)", err, raw)
}
if parsed.Scheme != "hysteria2" {
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
}
return parsed
}
func TestBuildHysteria2Url_GeckoObfs(t *testing.T) {
raw := buildHysteria2Url(
"con-pass",
"vpn.example.com",
443,
bo.ObfsShareConfig{Type: "gecko", Password: "gecko-secret"},
"vpn.example.com",
"",
)
q := mustParse(t, raw).Query()
if q.Get("obfs") != "gecko" {
t.Fatalf("expected obfs=gecko, got %q", q.Get("obfs"))
}
if q.Get("obfs-password") != "gecko-secret" {
t.Fatalf("expected gecko obfs password, got %q", q.Get("obfs-password"))
}
if q.Get("sni") != "vpn.example.com" {
t.Fatalf("expected sni, got %q", q.Get("sni"))
}
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
}
func TestBuildHysteria2Url_SalamanderObfs(t *testing.T) {
raw := buildHysteria2Url(
"con-pass",
"vpn.example.com",
443,
bo.ObfsShareConfig{Type: "salamander", Password: "salamander-secret"},
"vpn.example.com",
"",
)
q := mustParse(t, raw).Query()
if q.Get("obfs") != "salamander" {
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
}
if q.Get("obfs-password") != "salamander-secret" {
t.Fatalf("expected salamander obfs password, got %q", q.Get("obfs-password"))
}
}
func TestBuildHysteria2Url_NoObfs(t *testing.T) {
raw := buildHysteria2Url("pass", "example.com", 8443, bo.ObfsShareConfig{}, "", "")
parsed := mustParse(t, raw)
if parsed.Host != "example.com:8443" {
t.Fatalf("unexpected host: %s", parsed.Host)
}
q := parsed.Query()
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
}
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
}
func TestBuildHysteria2Url_UnknownObfsTypeIsNotShared(t *testing.T) {
// Неизвестный тип не должен попадать в ссылку: клиент получил бы
// параметр, который не понимает.
raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "future-obfs", Password: "x"}, "", "")
q := mustParse(t, raw).Query()
if q.Get("obfs") != "" {
t.Fatalf("unknown obfs type must not be shared, got %q", q.Get("obfs"))
}
}
func TestBuildHysteria2Url_ObfsWithoutPasswordIsNotShared(t *testing.T) {
raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "gecko"}, "", "")
q := mustParse(t, raw).Query()
if q.Get("obfs") != "" || q.Get("obfs-password") != "" {
t.Fatalf("obfs without password must not be shared: %s", q.Encode())
}
}
func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
raw := buildHysteria2Url(
"u@ser:#&=+ pass",
"example.com",
443,
"salamander",
"obf+s&pass=@x",
bo.ObfsShareConfig{Type: "gecko", Password: "obf+s&pass=@x"},
"exa mple.com",
"my remark #1",
)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v", err)
}
if parsed.Scheme != "hysteria2" {
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
}
parsed := mustParse(t, raw)
if parsed.User == nil {
t.Fatal("expected userinfo to be present")
}
@@ -32,42 +121,200 @@ func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
}
q := parsed.Query()
if q.Get("obfs") != "salamander" {
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
}
if q.Get("obfs-password") != "obf+s&pass=@x" {
t.Fatalf("expected decoded obfs-password, got %q", q.Get("obfs-password"))
}
if q.Get("sni") != "exa mple.com" {
t.Fatalf("expected decoded sni, got %q", q.Get("sni"))
}
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
if parsed.Fragment != "my remark #1" {
t.Fatalf("expected decoded fragment, got %q", parsed.Fragment)
}
if strings.Contains(raw, "u@ser:#&=+ pass") {
t.Fatalf("raw uri must not contain unescaped userinfo: %s", raw)
}
}
func TestBuildHysteria2Url_MinimalConfig(t *testing.T) {
raw := buildHysteria2Url("pass", "example.com", 8443, "", "", "", "")
// Регрессия на upstream-баг 2.9.3: `+` в credentials при разборе share link
// превращался в пробел. Проверяем, что кодирование однозначно.
func TestBuildHysteria2Url_PlusInCredentialsSurvivesRoundTrip(t *testing.T) {
cases := []string{"a+b", "a b", "a#b", "a@b", "a/b", "a?b", "a&b", "a=b", "a%b", "тест"}
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v", err)
for _, value := range cases {
raw := buildHysteria2Url(value, "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: value}, "", "")
parsed := mustParse(t, raw)
if got := parsed.User.Username(); got != value {
t.Fatalf("userinfo round-trip failed for %q: got %q (raw=%s)", value, got, raw)
}
if parsed.Host != "example.com:8443" {
t.Fatalf("unexpected host: %s", parsed.Host)
}
q := parsed.Query()
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
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)
}
}
// Ключевой инвариант: литеральный `+` кодируется как %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)
}
space := buildHysteria2Url("a b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a b"}, "", "")
if mustParse(t, space).Query().Get("obfs-password") == "a+b" {
t.Fatalf("space and '+' must not collapse to the same value: %s", space)
}
}
func TestResolveShareSni(t *testing.T) {
tests := []struct {
name string
acmeDomain string
publicHost string
envDomain string
want string
}{
{name: "acme domain wins", acmeDomain: "acme.example.com", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "acme.example.com"},
{name: "file tls falls back to product domain", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "env.example.com"},
{name: "public host used when domain is empty", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "", want: "vpn.example.com"},
{name: "ipv4 public host is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "", want: ""},
{name: "ipv4 env domain is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "198.51.100.7", want: ""},
{name: "whitespace is trimmed", acmeDomain: " acme.example.com ", publicHost: "", envDomain: "", want: "acme.example.com"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HY2XS_DOMAIN", tc.envDomain)
if got := resolveShareSni(tc.acmeDomain, tc.publicHost); got != tc.want {
t.Fatalf("resolveShareSni(%q, %q) with HY2XS_DOMAIN=%q = %q, want %q",
tc.acmeDomain, tc.publicHost, tc.envDomain, got, tc.want)
}
})
}
}
func TestObfsShare_FromServerConfig(t *testing.T) {
tests := []struct {
name string
yamlConfig string
wantType string
wantPassword string
}{
{
name: "gecko",
yamlConfig: "obfs:\n type: gecko\n gecko:\n password: gecko-pass\n minPacketSize: 512\n maxPacketSize: 1200\n",
wantType: "gecko",
wantPassword: "gecko-pass",
},
{
name: "salamander",
yamlConfig: "obfs:\n type: salamander\n salamander:\n password: salamander-pass\n",
wantType: "salamander",
wantPassword: "salamander-pass",
},
{
name: "no obfs section",
yamlConfig: "listen: 0.0.0.0:443\n",
},
{
name: "type without matching subsection",
yamlConfig: "obfs:\n type: gecko\n salamander:\n password: mismatched\n",
},
{
name: "unknown type",
yamlConfig: "obfs:\n type: future\n gecko:\n password: p\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var config bo.Hysteria2ServerConfig
if err := yaml.Unmarshal([]byte(tc.yamlConfig), &config); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
got := config.ObfsShare()
if got.Type != tc.wantType || got.Password != tc.wantPassword {
t.Fatalf("ObfsShare() = %+v, want type=%q password=%q", got, tc.wantType, tc.wantPassword)
}
})
}
}
func TestHysteria2ServerConfig_ParsesModernUpstreamSchema(t *testing.T) {
raw := `listen: 0.0.0.0:443
tls:
cert: /etc/hysteria/server.crt
key: /etc/hysteria/server.key
clientCA: /etc/hysteria/client-ca.crt
ech:
keyPath: /etc/hysteria/ech.pem
obfs:
type: gecko
gecko:
password: p
minPacketSize: 512
maxPacketSize: 1200
bandwidth:
up: 50 mbps
down: 50 mbps
disableLossCompensation: false
congestion:
type: bbr
bbrProfile: standard
quic:
disableStatelessReset: false
mimic:
enabled: false
interface: eth0
xdpMode: skb
realm:
stunServers:
- stun.example.com:3478
ipMode: dual
portMapping:
enabled: false
timeout: 10s
masquerade:
type: proxy
proxy:
url: https://example.com
rewriteHost: true
insecure: false
xForwarded: true
trafficStats:
listen: 127.0.0.1:36712
`
var config bo.Hysteria2ServerConfig
if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if config.ECH == nil || config.ECH.KeyPath == nil || *config.ECH.KeyPath != "/etc/hysteria/ech.pem" {
t.Fatal("ech.keyPath was not parsed")
}
if config.TLS == nil || config.TLS.ClientCA == nil || *config.TLS.ClientCA != "/etc/hysteria/client-ca.crt" {
t.Fatal("tls.clientCA was not parsed")
}
if config.Congestion == nil || config.Congestion.Type == nil || *config.Congestion.Type != "bbr" {
t.Fatal("congestion.type was not parsed")
}
if config.Congestion.BBRProfile == nil || *config.Congestion.BBRProfile != "standard" {
t.Fatal("congestion.bbrProfile was not parsed")
}
if config.Bandwidth == nil || config.Bandwidth.DisableLossCompensation == nil || *config.Bandwidth.DisableLossCompensation {
t.Fatal("bandwidth.disableLossCompensation was not parsed")
}
if config.QUIC == nil || config.QUIC.DisableStatelessReset == nil || *config.QUIC.DisableStatelessReset {
t.Fatal("quic.disableStatelessReset was not parsed")
}
if config.Mimic == nil || config.Mimic.Enabled == nil || *config.Mimic.Enabled {
t.Fatal("mimic section was not parsed")
}
if config.Realm == nil || len(config.Realm.StunServers) != 1 || config.Realm.PortMapping == nil {
t.Fatal("realm section was not parsed")
}
if config.Masquerade == nil || config.Masquerade.Proxy == nil ||
config.Masquerade.Proxy.XForwarded == nil || !*config.Masquerade.Proxy.XForwarded {
t.Fatal("masquerade.proxy.xForwarded was not parsed")
}
}
+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**
- QUIC/UDP
- один фиксированный UDP-порт
- `Salamander` включён по умолчанию
- `Gecko` включён по умолчанию, `Salamander` доступен как режим совместимости
- IPv4-only
- лимит по умолчанию: 50/50 Mbps на клиента
- fallback congestion controller: BBR (профиль `standard`)
### UI слой
- **HY2XS admin** — штатный компонент проекта
@@ -65,7 +66,7 @@ Target layer **не содержит сборщика** и **не выполня
## Принципы
### 1. Ядро, UI и оркестратор ведут себя по-разному
- Hysteria2: берём свежую upstream-версию при установке
- Hysteria2: последнюю стабильную upstream-версию выбирает **сборка пакета**, установка ставит уже замороженный артефакт
- HY2XS admin: разрабатываем **внутри проекта** и поставляем его сами
- Оркестратор: пишем на **Bun + TypeScript**, но собираем **локально**, а не на target
+81 -13
View File
@@ -43,13 +43,17 @@ Builder не является частью target install flow: на target serv
## Что делает builder
1. Проверяет структуру проекта.
2. Собирает / подготавливает HY2XS admin.
3. Компилирует оркестратор из Bun/TypeScript в install-артефакт.
4. Копирует артефакты UI в package staging directory.
5. Кладёт entrypoint, templates, docs и service files.
6. Формирует итоговый install package.
7. При необходимости считает manifest/checksum.
8. Выдаёт один переносимый результат для target machine.
2. Прогоняет тесты и типы оркестратора.
3. Разрешает upstream-версию Hysteria и проходит compatibility gate.
4. Компилирует оркестратор из Bun/TypeScript в install-артефакт.
5. Собирает / подготавливает HY2XS admin.
6. Прогоняет тесты HY2XS admin (после сборки frontend: `go:embed all:dist` требует готовых ассетов).
7. Копирует артефакты UI в package staging directory.
8. Кладёт entrypoint, templates, docs и service files.
9. Формирует итоговый install package.
10. Считает manifest/checksum.
11. Проверяет архив и прогоняет acceptance-проверки.
12. Выдаёт один переносимый результат для target machine.
## Что builder не делает
@@ -119,14 +123,76 @@ project/
Сам бинарь Hysteria2 **не вендорится** в install package как baseline-правило.
Причина:
- ядро Hysteria рассматривается как stable upstream component
- целевая установка должна брать его с official upstream на момент развёртывания
- ядро Hysteria рассматривается как stable upstream component;
- целевая установка скачивает его с official upstream, но **строго по замороженным координатам**.
### Разрешение версии на сборке
```text
SOURCE
resolve latest stable (HyNetworks/hysteria, только теги app/vX.Y.Z)
resolve exact release asset (hysteria-linux-amd64)
download + compute SHA-256
compatibility gate (реальный бинарник принимает канонический конфиг HY2XS)
PACKAGE METADATA
version = vX.Y.Z
exact_url = <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.url`, `metadata/hysteria.sha256`);
- версия, URL и SHA256 фиксируются в metadata install package (`metadata/hysteria.version`, `metadata/hysteria.url`, `metadata/hysteria.sha256`);
- способ выбора версии фиксируется в `metadata/hysteria.resolution` и `metadata/package.env`;
- runtime `reconfigure` не обновляет и не откатывает бинарник Hysteria2;
- install flow валидирует SHA256 и фактическую версию установленного бинарника.
- install flow валидирует SHA256 и фактическую версию установленного бинарника;
- install-time код не обращается к upstream API и не использует moving `latest` — это проверяется тестами и acceptance-шагом сборки.
### Compatibility gate
Gate защищает от ситуации, когда upstream меняет схему конфигурации, а builder молча собирает неработающий HY2XS.
Порядок:
1. скачать артефакт и сверить SHA-256;
2. сверить `hysteria version` с разрешённой версией;
3. отрендерить канонический конфиг HY2XS тем же кодом, что работает на target (`orchestrator/tools/render-canonical-config.ts`);
4. запустить реальный бинарник Hysteria с этим конфигом — для Gecko и для Salamander;
5. только после этого собирать release package.
При несовместимости ломается сборка:
```text
BUILD FAILED: unsupported Hysteria stable v2.13.0
```
Это осознанно: ошибка должна проявиться на build machine, а не на сервере оператора.
## Инварианты
@@ -137,4 +203,6 @@ project/
3. на сервере нет отдельного build step
4. bundled UI уже находится внутри пакета
5. оркестратор authored as Bun/TypeScript, но на target приходит как готовый install-артефакт
6. Hysteria2 подтягивается install layer'ом с upstream, а не собирается на target из исходников
6. Hysteria2 подтягивается install layer'ом с upstream по замороженным координатам, а не собирается на target из исходников
7. выход новой версии Hysteria после сборки не меняет содержимое уже собранного пакета
8. несовместимый upstream ломает сборку, а не установку у пользователя
+146 -18
View File
@@ -18,13 +18,53 @@ Hysteria2 — основной транспортный компонент се
## Версионная политика
С учётом выбранной модели «берём свежее из upstream» фиксируется такая практика:
Ключевое правило: **«последняя стабильная» определяется на этапе сборки пакета, а не на целевом сервере.**
- по умолчанию install layer тянет **свежий upstream release / install source**
- фактически установленная версия обязательно записывается в `post-install.env`
- бинарник Hysteria2 устанавливается только на этапе `install`
- версия, URL и SHA256 фиксируются в metadata install package
- `reconfigure` не обновляет и не откатывает бинарник Hysteria2
Не «HY2XS использует Hysteria vX.Y.Z», а:
> HY2XS по умолчанию берёт последний стабильный релиз Hysteria, доступный на момент сборки пакета. Разрешённая версия, URL артефакта и контрольная сумма замораживаются в получившемся install package.
Практика:
- builder обращается к каноническому upstream `HyNetworks/hysteria`;
- принимаются только стабильные релизы с тегом вида `app/vX.Y.Z`, без draft и prerelease;
- берётся ровно один артефакт `hysteria-linux-amd64`, URL используется в том виде, в каком его отдал upstream API;
- SHA-256 считается локально от скачанного артефакта, а не берётся из стороннего файла;
- версия, URL и SHA-256 фиксируются в metadata install package;
- на target-сервере **никогда** не используется moving `latest`;
- бинарник Hysteria2 устанавливается только на этапе `install`;
- фактически установленная версия записывается в `post-install.env`;
- `reconfigure` не обновляет и не откатывает бинарник Hysteria2.
Следствие: если между сборкой пакета и его установкой выйдет новая версия Hysteria, содержимое установки **не изменится под ногами**. Повторная установка старого пакета поставит ту же версию, что и в день сборки.
Переопределения builder:
```bash
HYSTERIA_CHANNEL=stable # по умолчанию: разрешить последнюю стабильную
HYSTERIA_CHANNEL=pinned # взять закоммиченный tools/build/hysteria-lock.env, без сети
HYSTERIA_VERSION_OVERRIDE=v2.12.2 # закрепить конкретную версию
```
## Compatibility gate
Автоматический выбор «последней стабильной» без проверки опасен: upstream может изменить схему конфигурации, и builder молча соберёт неработающий HY2XS.
Поэтому до создания release-пакета builder:
1. скачивает артефакт и сверяет SHA-256;
2. сверяет `hysteria version` с разрешённой версией;
3. рендерит канонический конфиг HY2XS **тем же кодом**, который работает на target-сервере;
4. запускает реальный бинарник Hysteria с этим конфигом — отдельно для Gecko и для Salamander;
5. только после этого формирует пакет.
Если upstream несовместим, ломается **сборка**:
```text
BUILD FAILED: unsupported Hysteria stable v2.13.0
```
а не production-сервер оператора.
## Платформа
@@ -48,14 +88,55 @@ Hysteria2 — основной транспортный компонент се
## Обфускация
В baseline включается:
- `obfs.type: salamander`
- `obfs.password`
Новые установки HY2XS используют **Gecko**.
Gecko помечен upstream как **experimental**. Он достраивается поверх Salamander: помимо scramble он дополнительно фрагментирует QUIC handshake на пакеты случайного размера. HY2XS использует upstream-defaults размеров пакетов `512/1200` как проверенный production-профиль.
**Salamander остаётся полностью поддержанным режимом совместимости.** Смена типа обфускации требует соответствующих изменений на клиенте: это изменение wire-совместимости, а не косметическая настройка.
Baseline:
```yaml
obfs:
type: gecko
gecko:
password: "<сгенерированный пароль>"
minPacketSize: 512
maxPacketSize: 1200
```
Режим совместимости:
```yaml
obfs:
type: salamander
salamander:
password: "<сгенерированный пароль>"
```
Правила:
- пароль должен быть сильным
- пароль должен фиксироваться в конфигурационном контуре
- значение должно быть доступно оператору через runtime config и `post-install.env`
- тип выбирается через `HY2XS_HYSTERIA_OBFS_TYPE` (`gecko` | `salamander`);
- пароль должен быть сильным, генерируется автоматически при `__GENERATE__` или пустом значении;
- пароль фиксируется в конфигурационном контуре и доступен оператору через runtime config и `post-install.env`;
- `obfs`-блок формируется оркестратором целиком, а не собирается из отдельных placeholders внутри YAML — комбинация вида `type: gecko` рядом с блоком `salamander` структурно невозможна.
### Почему размеры пакетов Gecko не вынесены в env
Официальная схема `hysteria2://` передаёт только тип обфускации и пароль. `minPacketSize` и `maxPacketSize` в ссылку не помещаются.
Если разрешить оператору произвольные значения, сгенерированная клиентская ссылка перестанет полностью описывать подключение и потребуется отдельный формат — выгружаемый клиентский профиль. Пока такой задачи нет, фиксация `512/1200` даёт корректную ссылку и воспроизводимое поведение.
Валидация (на случай будущего расширения) централизована в оркестраторе: `min > 0`, `max >= min`, `max <= 2048`.
## Версия схемы конфигурации
```bash
HY2XS_CONFIG_SCHEMA_VERSION=2
```
Пакет понимает только свою версию схемы. Конфигурация с другой версией отклоняется fail-fast, а не применяется частично.
HY2XS `v1` **не мигрирует установки `0.x` на месте**: между `0.x` и `1.0.0` изменились схема конфигурации, тип обфускации по умолчанию и контракт выбора версии Hysteria. Переход выполняется чистой установкой.
## TLS
@@ -72,8 +153,9 @@ Hysteria2 — основной транспортный компонент се
- `acme` block обязан содержать `type: http|tls` из runtime env (`HY2XS_ACME_TYPE`);
- `HY2XS_ACME_TYPE=dns` в production-профиле запрещён до отдельной реализации;
- `HY2XS_HYSTERIA_AUTH_MODE` зафиксирован в `http` и валидируется fail-fast;
- `HY2XS_HYSTERIA_OBFS_TYPE` зафиксирован в `salamander` и валидируется fail-fast;
- блок `masquerade` в baseline не задаётся (допустимо, но приводит к `404 Not Found` на обычный HTTP трафик);
- `HY2XS_HYSTERIA_OBFS_TYPE` принимает `gecko` (default) или `salamander` и валидируется fail-fast;
- блок `masquerade` в baseline не задаётся: при включённой обфускации сервер и так перестаёт быть обычным HTTP/3 endpoint, поэтому masquerade не даёт выигрыша, а `404 Not Found` на обычный HTTP-трафик — ожидаемое поведение;
- `ech` в baseline не включается: при включённой обфускации соединение целиком перестаёт выглядеть как обычный QUIC, поэтому ECH не даёт дополнительной выгоды (он полезен в bare-режиме);
- `file` -> только `tls.cert`/`tls.key` block;
- `self_signed_dev` -> только dev сценарии.
@@ -91,11 +173,55 @@ Hysteria2 — основной транспортный компонент се
Серверная baseline policy:
- `bandwidth.up = 50 mbps`
- `bandwidth.down = 50 mbps`
- `bandwidth.disableLossCompensation = false`
- `ignoreClientBandwidth = false`
- `congestion.type = bbr`
- `congestion.bbrProfile = standard`
Важно:
- эти параметры сами по себе не исчерпывают speed policy
- корректный лимит ожидается только в паре с совместимым клиентским конфигом
- эти параметры сами по себе не исчерпывают speed policy;
- корректный лимит ожидается только в паре с совместимым клиентским конфигом;
- `congestion` — это **fallback** controller: он применяется, когда Brutal bandwidth не согласован сторонами. Подробнее — в [06-speed-limits-and-congestion.md](06-speed-limits-and-congestion.md).
## QUIC stateless reset
```yaml
quic:
disableStatelessReset: false
```
Начиная с Hysteria 2.12.1 сервер отправляет stateless reset, чтобы клиент со stale-соединением после перезапуска сервера или сна устройства переподключался сразу, а не по таймауту. В 2.12.2 появилась возможность это отключить.
Для VPN-подобного применения HY2XS быстрый reconnect — плюс, поэтому механизм остаётся включённым, а значение фиксируется в конфиге явно.
## Возможности вне default-профиля
HY2XS обязан **понимать** современную схему Hysteria, но не обязан включать всё подряд. Разделяются три уровня:
| Возможность | Генерирует HY2XS | Читает и сохраняет | Отдельный профиль |
| --- | :-: | :-: | :-: |
| Gecko | да | да | — |
| Salamander | fallback | да | — |
| BBR / bbrProfile | да | да | — |
| Loss compensation | да | да | — |
| QUIC stateless reset | да | да | — |
| ECH | нет | да | позже |
| Mimic | нет | да | позже |
| Realms | нет | да | позже |
| Port hopping | нет | да | позже |
| ACME DNS | нет | да | позже |
| Masquerade | нет | да | позже |
Причина не в качестве этих возможностей, а в том, что каждая меняет соседнюю подсистему:
- **Mimic** — привилегии, eBPF/XDP, сторонний бинарник, требования к клиенту; текущий systemd-контракт намеренно запускает Hysteria под непривилегированным пользователем с `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`, поэтому Mimic несовместим с ним по построению и требует отдельного security-профиля;
- **Realms** — сетевая топология (STUN/hole punching вместо публичного IPv4 и own nftables);
- **Port hopping** — nftables и capabilities; официально несовместим с Mimic;
- **ECH** — жизненный цикл ключей и распространение конфигурации клиентам (Hysteria не генерирует ECH keypair сама);
- **ACME DNS** — учётные данные провайдера и работа с секретами;
- **Masquerade** — дополнительное web/proxy-поведение.
Ни одна из них не должна включаться toggle'ом, который незаметно меняет systemd capabilities или топологию firewall.
## Рекомендуемые пути
@@ -108,8 +234,8 @@ Hysteria2 — основной транспортный компонент се
После установки должно быть верно:
1. Hysteria2 получена из official upstream
2. фактическая версия отражена в `post-install.env`
1. Hysteria2 получена из official upstream по замороженному в пакете URL и SHA-256
2. фактическая версия совпадает с версией из metadata пакета и отражена в `post-install.env`
3. конфиг валиден
4. сервис стартует через systemd
5. нужный UDP-порт реально слушается
@@ -118,3 +244,5 @@ Hysteria2 — основной транспортный компонент се
8. `trafficStats.secret` отдельный от `JWT_SECRET`
9. IPv6 listen не используется
10. публичные клиентские endpoint/URL берутся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen`/request-host
11. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации и пароль, и совместимый клиент подключается по ней напрямую
12. SNI в ссылке берётся из ACME-домена, затем из `HY2XS_DOMAIN`, затем из `HY2XS_PUBLIC_HOST`; IP-адрес как SNI не используется
+54 -1
View File
@@ -51,6 +51,54 @@ Target server **не собирает** admin-компонент из исход
HY2XS admin работает как надстройка над Hysteria YAML/API-слоем. Это нормально: важно только, чтобы источник истины по runtime-состоянию был понятен и не было двух конкурирующих конфигурационных миров без правил синхронизации.
Относительно конфигурации Hysteria панель **read-only**: конфиг генерирует оркестратор.
## Два слоя работы с конфигом Hysteria
Это важное архитектурное разделение.
| Слой | Назначение | Поведение при неизвестных полях |
| --- | --- | --- |
| Типизированная модель | отображение известных HY2XS полей в UI | неизвестные поля не отображаются |
| Сырой YAML | экспорт и сохранение | неизвестные поля **сохраняются** |
Причина: если бы экспорт работал через типизированную модель (`Unmarshal` → структура → `Marshal`), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
Поэтому:
- экспорт читает исходный YAML и сохраняет структуру документа целиком;
- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
### Санитайз экспорта
Экспортируемый файл покидает сервер, поэтому секреты из него вырезаются:
- пароли обфускации (`obfs.*.password`);
- `trafficStats.secret`;
- `access_token` в auth-URL и учётные данные, встроенные в URL;
- `auth.password`, `auth.userpass`;
- учётные данные ACME DNS-провайдера;
- любые **неизвестные** поля, имя которых содержит `password`, `secret`, `token` или `credential`.
Последний пункт — обратная сторона сохранения неизвестных полей: новое upstream-поле с секретом вырезается ещё до того, как HY2XS про него узнает.
Пути к файлам (`tls.cert`, `tls.key`, `ech.keyPath`, `tls.clientCA`) секретами не считаются и остаются читаемыми — они нужны для диагностики.
## Модель современной схемы Hysteria
Модель админки понимает актуальную серверную схему, даже там, где UI не позволяет ничего включить: `obfs.gecko`, `ech`, `congestion`, `mimic`, `realm`, `tls.clientCA`, `quic.disableStatelessReset`, `bandwidth.disableLossCompensation`, `masquerade.proxy.xForwarded`.
Смысл в том, чтобы admin **понимал текущую upstream-схему**, а не считал неизвестными поля собственного конфига.
## Генерация клиентских ссылок
- тип обфускации и пароль берутся из фактического конфига одинаково для всех поддерживаемых типов (`gecko`, `salamander`);
- неизвестный тип обфускации в ссылку не попадает: лучше отсутствие параметра, чем параметр, который клиент не понимает;
- публичный endpoint берётся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen` или Host-заголовка запроса;
- SNI берётся из ACME-домена, затем из `HY2XS_DOMAIN`, затем из `HY2XS_PUBLIC_HOST`; IP-адрес как SNI не используется;
- `minPacketSize`/`maxPacketSize` Gecko в ссылку не помещаются — поэтому HY2XS держит их на upstream-defaults `512/1200`.
## Правила ответственности
### Source of truth
@@ -81,7 +129,9 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP
- склеивать unit Hysteria2 и unit HY2XS admin в один сервис;
- раздувать оркестратор из-за особенностей панели;
- использовать HY2XS admin как updater бинаря Hysteria2;
- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API.
- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API;
- экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
- выгружать конфиг с секретами в открытом виде.
## Что фиксировать в `post-install.env`
@@ -107,3 +157,6 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP
5. Hysteria2 остаётся внешним vanilla upstream-компонентом
6. HY2XS admin не выступает updater-менеджером Hysteria2
7. `trafficStats.secret` не связан с `JWT_SECRET`
8. экспорт конфига сохраняет неизвестные upstream-поля
9. экспорт конфига не содержит секретов
10. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую
+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
### На сервере
- `bandwidth.up = 50 mbps`
- `bandwidth.down = 50 mbps`
- `bandwidth.disableLossCompensation = false`
- `ignoreClientBandwidth = false`
- `congestion.type = bbr`
- `congestion.bbrProfile = standard`
### На клиенте
Совместимый клиентский конфиг должен задавать соответствующие bandwidth hints:
@@ -29,13 +44,30 @@
Ожидаемый 50/50 Mbps contract считается корректным только тогда, когда сервер и клиентская конфигурация согласованы.
Логика выбора внутри Hysteria:
- когда стороны согласовали Brutal bandwidth — используется Brutal;
- когда это не применяется — используется выбранный fallback congestion controller.
Поэтому BBR тоже является частью явного baseline HY2XS, а не «настройкой по умолчанию, о которой можно не думать».
## Loss compensation
```yaml
bandwidth:
disableLossCompensation: false
```
Компенсация потерь (появилась в Hysteria 2.10.0) позволяет отправлять быстрее заданной полосы, чтобы компенсировать потерю пакетов. В baseline HY2XS она **включена**, а значение фиксируется в конфиге явно — проект про воспроизводимое поведение, а не про молчаливое следование upstream-дефолтам.
## Что делать с host-level BBR
`net.ipv4.tcp_congestion_control=bbr` можно оставить как общий системный тюнинг, но:
- это не главный механизм speed policy Hysteria2
- это не замена клиентским bandwidth hints
- это не центр документации по лимитам
- это не главный механизм speed policy Hysteria2;
- это не замена клиентским bandwidth hints;
- это **не то же самое**, что `congestion.type: bbr` в конфиге Hysteria — у Hysteria собственный congestion-control контур поверх QUIC;
- это не центр документации по лимитам.
## Что фиксировать в `post-install.env`
@@ -43,8 +75,11 @@
- `HY2_BANDWIDTH_UP`
- `HY2_BANDWIDTH_DOWN`
- `HY2_IGNORE_CLIENT_BANDWIDTH`
- `HY2_DISABLE_LOSS_COMPENSATION`
- `HY2_CONGESTION_TYPE`
- `HY2_BBR_PROFILE`
Дополнительно фиксируется `HY2_VERSION` как фактически установленная версия Hysteria2.
Дополнительно фиксируется `HY2_VERSION` как фактически установленная версия Hysteria2 и `HY2_RESOLUTION` как способ её выбора при сборке пакета.
## Что нельзя писать в проектных доках
@@ -52,6 +87,8 @@
- «лимит задаётся только на сервере, клиент не важен»
- «любой URI достаточно для полной speed policy»
- «host BBR и есть логика Hysteria»
- «50 mbps — оптимальная скорость Hysteria» (это политика HY2XS, а не свойство протокола)
- «Brutal и congestion controller — одно и то же»
## Правильная baseline-формулировка
+10 -2
View File
@@ -59,6 +59,7 @@
- `ORCH_ENTRYPOINT`
### Общие
- `HY2XS_CONFIG_SCHEMA_VERSION`
- `DEPLOY_DOMAIN`
- `PUBLIC_HOST`
- `PUBLIC_PORT`
@@ -71,7 +72,8 @@
### Hysteria
- `HY2_SOURCE=official-upstream`
- `HY2_VERSION`
- `HY2_VERSION` — фактически установленная версия
- `HY2_RESOLUTION` — как версия была выбрана при сборке: `latest-stable`, `pinned` или `override`
- `HY2_TLS_MODE`
- `HY2_ACME_EMAIL`
- `HY2_TLS_CERT_PATH`
@@ -81,11 +83,17 @@
- `HY2_AUTH_MODE`
- `HY2_AUTH_URL`
- `HY2_TRAFFIC_STATS_LISTEN`
- `HY2_OBFS_TYPE`
- `HY2_OBFS_TYPE``gecko` или `salamander`
- `HY2_OBFS_PASSWORD`
- `HY2_GECKO_MIN_PACKET_SIZE`
- `HY2_GECKO_MAX_PACKET_SIZE`
- `HY2_BANDWIDTH_UP`
- `HY2_BANDWIDTH_DOWN`
- `HY2_DISABLE_LOSS_COMPENSATION`
- `HY2_IGNORE_CLIENT_BANDWIDTH`
- `HY2_CONGESTION_TYPE`
- `HY2_BBR_PROFILE`
- `HY2_DISABLE_STATELESS_RESET`
- `HY2_CONFIG_PATH`
### HY2XS admin
+198
View File
@@ -4,6 +4,24 @@
Зафиксировать checklist для новой двухслойной схемы.
## Как запускать тесты
```bash
# Юнит-тесты и типы оркестратора
cd orchestrator && bun install --frozen-lockfile && bun run check && bun test
# Тесты и статический анализ HY2XS admin
cd apps && go vet ./... && go test ./...
# Полный E2E с реальным клиентом Hysteria (Debian 13 amd64)
HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
# Production-сборка: прогоняет тесты, резолвер и compatibility gate
./tools/build/build.sh
```
`build.sh` останавливается, если падают тесты оркестратора, тесты админки или compatibility gate.
## A. Builder layer tests
### Проверяем
@@ -16,6 +34,81 @@
7. builder сам доставляет отсутствующие build-зависимости
8. builder проверяет версии Go/Bun/Node.js/pnpm
9. builder пишет версии toolchain в metadata
10. builder прогоняет `bun test` и `go test` до упаковки
## A1. Latest-stable resolver
Фикстуры и ожидаемое поведение (`orchestrator/test/hysteria-release.test.ts`):
| Сценарий | Ожидание |
| --- | --- |
| stable `app/v2.12.2` | выбирается |
| prerelease `app/v2.13.0` | игнорируется |
| draft `app/v2.14.0` | игнорируется |
| чужое семейство тегов (`core/`, `docs/`) | игнорируется |
| тег без префикса `app/` | игнорируется |
| `app/v2.9.10` против `app/v2.9.2` | выбирается `2.9.10` (числовое сравнение, не строковое) |
| отсутствует `hysteria-linux-amd64` | ошибка |
| дублирующийся `hysteria-linux-amd64` | ошибка, а не случайный выбор |
| non-https URL артефакта | ошибка |
| невалидный semver в теге | игнорируется |
| пустой список релизов | понятная ошибка |
| несовпадение SHA-256 | сборка падает |
| сетевая ошибка / rate limit | понятная ошибка с подсказкой про `GITHUB_TOKEN` и `HYSTERIA_CHANNEL=pinned` |
Отдельно проверяется, что **`latest stable` — это именно stable, а не максимальная строка или самый свежий тег**.
## A2. Release rollover
Ключевой acceptance-критерий модели «latest на сборке»:
```text
Сегодня: latest = 2.12.2 → пакет A закрепляет 2.12.2
Завтра: latest = 2.12.3 → пакет B закрепляет 2.12.3
Повторная установка пакета A всё равно ставит 2.12.2
```
Проверяется на двух уровнях:
- резолвер даёт разный результат на разных снимках upstream (`orchestrator/test/release-rollover.test.ts`);
- install-time код не импортирует резолвер, не обращается к `api.github.com` и не использует moving `latest` — это утверждение проверяется тестом и acceptance-шагом сборки.
## A3. Compatibility gate
1. скачанный артефакт проходит проверку SHA-256;
2. `hysteria version` совпадает с разрешённой версией;
3. реальный бинарник принимает канонический конфиг HY2XS для Gecko;
4. то же для Salamander;
5. при несовместимости падает **сборка** с сообщением `BUILD FAILED: unsupported Hysteria stable vX.Y.Z`, а не установка у пользователя.
## A4. Конфигурационный контракт (unit)
Таблица `orchestrator/test/env.test.ts`:
| Вход | Ожидание |
| --- | --- |
| значение не задано | `gecko` |
| `gecko` | принято |
| `salamander` | принято |
| неизвестный тип | отклонено |
| `Gecko` (регистр) | отклонено |
| gecko `max < min` | отклонено |
| gecko `max > 2048` | отклонено |
| gecko `max == 2048` | принято |
| неположительный/нецелый `min` | отклонено |
| пустой obfs-пароль | автогенерация, а не пустое значение в конфиге |
| `HY2XS_CONFIG_SCHEMA_VERSION=1` | отклонено с указанием на чистую установку |
Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации».
Рендер конфига (`orchestrator/test/render-config.test.ts`):
- Gecko рендерит **только** gecko-подблок;
- Salamander рендерит **только** salamander-подблок;
- в конфиге никогда нет двух подтипов obfs одновременно;
- шаблон не содержит захардкоженного типа обфускации;
- пароль с пробелами и спецсимволами экранируется;
- YAML-инъекция через пароль отклоняется даже в обход env-валидации.
## B. Target install tests
@@ -56,6 +149,89 @@
21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin`
22. `hy2xs-admin` может читать `/etc/hysteria/config.yaml`, но не может писать
## C1. Семантический smoke конфига
Недостаточно `grep` по YAML: он не отличит нужное поле от такой же строки в другой секции и не заметит оставшийся рядом лишний подблок.
Smoke разбирает `/etc/hysteria/config.yaml` и сверяет с production-профилем:
```text
effective Hysteria version == версия из metadata пакета
obfs:
type == HY2XS_HYSTERIA_OBFS_TYPE
ровно один подблок, соответствующий type
password непустой
для gecko: minPacketSize == 512, maxPacketSize == 1200
bandwidth:
up/down == runtime env
disableLossCompensation == false
congestion:
type == bbr
bbrProfile == standard
quic:
disableStatelessReset == false
окна и таймауты == baseline
trafficStats:
listen == runtime env
secret непустой
auth:
type == http
url содержит machine access_token
TLS:
acme-режим не содержит секции tls
file-режим не содержит секции acme
```
## C2. End-to-end с реальным клиентом
`tools/test/e2e-hysteria.sh`, отдельно для Gecko и Salamander:
1. сервер принимает сгенерированный конфиг и стартует;
2. TLS handshake;
3. handshake с обфускацией;
4. HTTP auth HY2XS: разрешённый пир принят;
5. HTTP auth HY2XS: неразрешённый пир отклонён;
6. клиент подключается **именно по сгенерированной `hysteria2://` ссылке**;
7. TCP forwarding;
8. UDP forwarding;
9. `trafficStats` с валидным secret;
10. `trafficStats` с невалидным secret отклоняется;
11. per-peer accounting содержит аутентифицированного пира;
12. перезапуск сервера;
13. быстрое переподключение клиента (поведение stateless reset).
Пункт 6 — тот самый, который ловит класс ошибок, неизбежный при наивном включении Gecko: сервер работает, ссылка формально валидна, а клиент по ней не подключается.
## C3. Share URI (unit)
`apps/service/hysteria2_api_test.go`:
- Gecko URI содержит `obfs=gecko` и `obfs-password`;
- Salamander URI содержит `obfs=salamander` и `obfs-password`;
- конфиг без обфускации даёт ссылку без `obfs`;
- неизвестный тип обфускации в ссылку не попадает;
- обфускация без пароля в ссылку не попадает;
- SNI: ACME-домен → `HY2XS_DOMAIN``HY2XS_PUBLIC_HOST`, IP не используется;
- спецсимволы в credentials и obfs-пароле переживают round-trip: `+`, пробел, `#`, `@`, `/`, `?`, `&`, `=`, `%`, кириллица;
- литеральный `+` кодируется как `%2B` и не схлопывается с пробелом (регрессия на upstream-баг 2.9.3).
## C4. Экспорт конфига (unit)
`apps/service/hysteria2_export_test.go`:
- неизвестные upstream-секции переживают экспорт целиком, включая вложенные карты и списки;
- операционные поля остаются читаемыми;
- вырезаются: obfs-пароль, `trafficStats.secret`, `access_token`, `auth.userpass`, учётные данные ACME DNS, пароли outbound;
- вырезается **неизвестное** поле с секретным именем;
- пути к файлам (`tls.key`, `ech.keyPath`, `clientCA`) остаются видимыми.
## D. Negative tests
1. не Debian 13
@@ -68,6 +244,9 @@
8. install flow прерван посередине
9. попытка использовать `HY2XS_IPV6_ENABLED=true`
10. `HY2XS_PUBLIC_HOST=0.0.0.0`
11. неизвестный `HY2XS_HYSTERIA_OBFS_TYPE`
12. конфигурация со схемой `HY2XS_CONFIG_SCHEMA_VERSION` из линейки `0.x`
13. upstream `latest` несовместим с шаблоном HY2XS — падает сборка, не установка
## E. Fix20 production matrix (обязательные сценарии)
@@ -119,3 +298,22 @@
13. production build verify падает, если `config/hy2xs.env` содержит placeholder-значения
14. production build verify падает при dirty git tree (кроме `ALLOW_DIRTY_BUILD=true`)
15. metadata содержит `source_git_commit`, `dirty_tree`, `build_profile=production`
16. builder без override на сегодняшний день автоматически выбирает последнюю стабильную версию Hysteria
17. собранный пакет содержит **точные** версию, URL и SHA-256
18. выход новой версии Hysteria после сборки не меняет содержимое старого пакета
19. новая установка генерирует Gecko
20. Gecko использует `512/1200`
21. установленная Hysteria реально принимает сгенерированный YAML
22. сервис запускается под существующим непривилегированным пользователем `hysteria`
23. созданный пользователь получает `hysteria2://` с `obfs=gecko` и `obfs-password`
24. совместимый клиент Hysteria подключается напрямую по этой ссылке
25. после перезапуска Hysteria клиент быстро восстанавливает соединение
26. режим `HY2XS_HYSTERIA_OBFS_TYPE=salamander` полностью работоспособен
27. admin читает Gecko-конфиг без ошибок
28. экспорт не уничтожает современные и неизвестные upstream-поля
29. экспорт не содержит секретов
30. frontend отображает Gecko
31. `namedotcom` удалён, актуальные ACME-провайдеры отражены
32. документация нигде не утверждает, что Salamander — фиксированный инвариант
33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
+7 -2
View File
@@ -24,10 +24,12 @@
Здесь нет сборщика. Здесь запускается только итоговый install package / orchestrator, который:
- ставит системные зависимости
- разворачивает **встроенный HY2XS admin**
- забирает **свежую Hysteria2 из официального upstream**
- забирает **закреплённую в пакете Hysteria2 из официального upstream** и сверяет её по SHA-256 и версии
- создаёт конфиги, systemd unit-файлы и `post-install.env`
- выполняет базовую настройку сервера
Версия Hysteria2 выбирается **на builder layer**: последняя стабильная разрешается при сборке и замораживается в metadata пакета. Target layer никогда не обращается к moving `latest`.
## Базовые правила
1. Hysteria2 не вендорится и не собирается как часть HY2XS.
@@ -52,6 +54,9 @@
10. [10-access-layer-out-of-scope.md](10-access-layer-out-of-scope.md)
11. [11-testing-and-acceptance.md](11-testing-and-acceptance.md)
12. [12-operations-and-troubleshooting.md](12-operations-and-troubleshooting.md)
13. [13-production-runbook.md](13-production-runbook.md)
История изменений проекта — в [CHANGELOG.md](../CHANGELOG.md).
## Жёсткие рамки baseline
@@ -72,4 +77,4 @@
Правильная baseline-модель теперь такая:
**Локальный builder собирает install package с HY2XS admin и Bun/TypeScript оркестратором; серверный install-only orchestrator ставит этот пакет на чистый Debian 13, тянет свежую Hysteria2 из upstream, разворачивает HY2XS admin, создаёт systemd + nftables + post-install env и подготавливает рабочее серверное окружение.**
**Локальный builder разрешает последнюю стабильную Hysteria2, проверяет её на совместимость с конфигом HY2XS и собирает install package с HY2XS admin и Bun/TypeScript оркестратором; серверный install-only orchestrator ставит этот пакет на чистый Debian 13, скачивает ровно закреплённую Hysteria2, разворачивает HY2XS admin, создаёт systemd + nftables + post-install env и подготавливает рабочее серверное окружение.**
+1
View File
@@ -7,6 +7,7 @@
"packageManager": "bun@1.3.13",
"scripts": {
"check": "tsc --noEmit",
"test": "bun test",
"build": "bun build src/cli.ts --compile --target=bun-linux-x64 --outfile dist/hy2xs-orchestrator"
},
"devDependencies": {
+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"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
hysteriaVersion: await readInstalledHysteriaVersion()
hysteriaVersion: await readInstalledHysteriaVersion(),
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
};
step("doctor preflight");
+1
View File
@@ -159,6 +159,7 @@ export async function install(options: InstallOptions): Promise<void> {
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
hysteriaVersion: "unknown",
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown"),
hysteriaTargetVersion: await readPackageValue(options.packageDir, "hysteria.version", ""),
hysteriaArtifactUrl: await readPackageValue(options.packageDir, "hysteria.url", ""),
hysteriaArtifactSha256: await readPackageValue(options.packageDir, "hysteria.sha256", "")
+2 -1
View File
@@ -176,7 +176,8 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
hysteriaVersion: await readInstalledHysteriaVersion()
hysteriaVersion: await readInstalledHysteriaVersion(),
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
};
step("preflight");
+35 -9
View File
@@ -1,5 +1,12 @@
import { randomBytes } from "node:crypto";
import type { FirewallMode, RuntimeConfig, TlsMode } from "../types/context";
import {
GECKO_DEFAULT_MAX_PACKET_SIZE,
GECKO_DEFAULT_MIN_PACKET_SIZE,
HY2XS_CONFIG_SCHEMA_VERSION,
normalizeHysteriaObfsType,
validateGeckoPacketSizes
} from "./profile";
type EnvMap = Record<string, string>;
@@ -143,12 +150,22 @@ function normalizeFixedHysteriaAuthMode(value: string | undefined): "http" {
return "http";
}
function normalizeFixedHysteriaObfsType(value: string | undefined): "salamander" {
const obfsType = value || "salamander";
if (obfsType !== "salamander") {
throw new Error("HY2XS_HYSTERIA_OBFS_TYPE is fixed in HY2XS production profile: salamander");
function normalizeConfigSchemaVersion(value: string | undefined): number {
const raw = (value ?? "").trim();
if (!raw) {
return HY2XS_CONFIG_SCHEMA_VERSION;
}
return "salamander";
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`invalid HY2XS_CONFIG_SCHEMA_VERSION: ${value}`);
}
if (parsed !== HY2XS_CONFIG_SCHEMA_VERSION) {
throw new Error(
`unsupported HY2XS_CONFIG_SCHEMA_VERSION: ${parsed}. This package understands schema ${HY2XS_CONFIG_SCHEMA_VERSION}; ` +
"HY2XS v1 requires a clean installation and does not migrate v0 configuration in place."
);
}
return parsed;
}
function normalizeYamlSafeSecret(name: string, value: string): string {
@@ -191,9 +208,10 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
const dnsAaaaPolicy = normalizeDnsAaaaPolicy(env.HY2XS_DNS_AAAA_POLICY);
const firewallMode = normalizeFirewallMode(env);
const hysteriaAuthMode = normalizeFixedHysteriaAuthMode(env.HY2XS_HYSTERIA_AUTH_MODE);
const hysteriaObfsType = normalizeFixedHysteriaObfsType(env.HY2XS_HYSTERIA_OBFS_TYPE);
const hysteriaObfsType = normalizeHysteriaObfsType(env.HY2XS_HYSTERIA_OBFS_TYPE);
const config: RuntimeConfig = {
configSchemaVersion: normalizeConfigSchemaVersion(env.HY2XS_CONFIG_SCHEMA_VERSION),
domain: env.HY2XS_DOMAIN || "",
dnsAaaaPolicy,
publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""),
@@ -232,6 +250,10 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
"HY2XS_HYSTERIA_OBFS_PASSWORD",
valueOrGenerate(env.HY2XS_HYSTERIA_OBFS_PASSWORD)
),
// Gecko packet sizes не выносятся в env: share URI не умеет их передавать,
// поэтому любое нестандартное значение сделало бы hysteria2:// неполным.
hysteriaGeckoMinPacketSize: GECKO_DEFAULT_MIN_PACKET_SIZE,
hysteriaGeckoMaxPacketSize: GECKO_DEFAULT_MAX_PACKET_SIZE,
hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
hysteriaIgnoreClientBandwidth: parseBool(
@@ -251,6 +273,9 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
}
export function validateRuntimeConfig(config: RuntimeConfig): void {
if (config.hysteriaObfsType === "gecko") {
validateGeckoPacketSizes(config.hysteriaGeckoMinPacketSize, config.hysteriaGeckoMaxPacketSize);
}
if (config.ipv6Enabled) {
throw new Error("HY2XS is IPv4-only: HY2XS_IPV6_ENABLED must be false");
}
@@ -297,7 +322,8 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
export function renderRuntimeEnv(config: RuntimeConfig): string {
const lines = [
"# HY2XS runtime config (editable)",
"HY2XS_IPV6_ENABLED=false",
`HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`,
`HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`,
`HY2XS_DOMAIN=${config.domain}`,
`HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`,
`HY2XS_PUBLIC_HOST=${config.publicHost}`,
@@ -320,11 +346,11 @@ export function renderRuntimeEnv(config: RuntimeConfig): string {
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`,
"HY2XS_HYSTERIA_AUTH_MODE=http",
`HY2XS_HYSTERIA_AUTH_MODE=${config.hysteriaAuthMode}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`,
"HY2XS_HYSTERIA_OBFS_TYPE=salamander",
`HY2XS_HYSTERIA_OBFS_TYPE=${config.hysteriaObfsType}`,
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
+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 { readText, renderTemplate, writeText } from "../lib/fs";
import { runVisible } from "../lib/process";
import {
DISABLE_LOSS_COMPENSATION,
renderCongestionBlock,
renderObfsBlock,
renderQuicBlock
} from "../config/profile";
export async function generateConfig(context: RuntimeContext): Promise<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"
? `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}`
: "";
const template = await readText(`${context.options.packageDir}/templates/hysteria/config.yaml.tpl`);
const rendered = renderTemplate(template, {
return renderTemplate(template, {
HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
HYSTERIA_PORT: context.config.hysteriaPort,
HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword,
HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost,
HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort,
HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret,
UI_PORT: context.config.uiPort,
BANDWIDTH_UP: context.config.hysteriaBandwidthUp,
BANDWIDTH_DOWN: context.config.hysteriaBandwidthDown,
DISABLE_LOSS_COMPENSATION: DISABLE_LOSS_COMPENSATION ? "true" : "false",
IGNORE_CLIENT_BANDWIDTH: context.config.hysteriaIgnoreClientBandwidth ? "true" : "false",
OBFS_BLOCK: renderObfsBlock(context.config),
CONGESTION_BLOCK: renderCongestionBlock(),
QUIC_BLOCK: renderQuicBlock(),
TLS_ACME_BLOCK: tlsAcmeBlock,
TLS_FILE_BLOCK: tlsFileBlock,
AUTH_INSECURE: context.config.tlsMode === "self_signed_dev" ? "true" : "false"
});
}
export async function generateConfig(context: RuntimeContext): Promise<void> {
const template = await readText(hysteriaConfigTemplatePath(context.options.packageDir));
const rendered = renderHysteriaConfig(context, template);
const configPath = context.config.hysteriaConfigPath;
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 { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs";
import { runVisible } from "../lib/process";
import {
BBR_PROFILE,
CONGESTION_TYPE,
DISABLE_LOSS_COMPENSATION,
DISABLE_STATELESS_RESET
} from "../config/profile";
export async function writePostInstallEnv(context: RuntimeContext): Promise<void> {
const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/env/post-install.env.tpl`), {
PACKAGE_VERSION: context.packageVersion,
PACKAGE_BUILD_ID: context.packageBuildId,
CONFIG_SCHEMA_VERSION: context.config.configSchemaVersion,
LAST_APPLY_DATE: context.installDate,
DOMAIN: context.config.domain,
PUBLIC_HOST: context.config.publicHost,
@@ -14,16 +21,24 @@ export async function writePostInstallEnv(context: RuntimeContext): Promise<void
FIREWALL_MODE: context.config.firewallMode,
FIREWALL_STAGED_APPLY: context.config.firewallStagedApply ? "true" : "false",
HYSTERIA_VERSION: context.hysteriaVersion,
HYSTERIA_RESOLUTION: context.hysteriaResolution,
TLS_MODE: context.config.tlsMode,
ACME_EMAIL: context.config.acmeEmail,
TLS_CERT_PATH: context.config.tlsCertPath,
TLS_KEY_PATH: context.config.tlsKeyPath,
HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
HYSTERIA_PORT: context.config.hysteriaPort,
OBFS_TYPE: context.config.hysteriaObfsType,
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_DOWN: context.config.hysteriaBandwidthDown,
DISABLE_LOSS_COMPENSATION: DISABLE_LOSS_COMPENSATION ? "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_PORT: context.config.hysteriaTrafficStatsPort,
HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret,
+29 -11
View File
@@ -1,6 +1,8 @@
import type { RuntimeContext } from "../types/context";
import { info } from "../lib/log";
import { readText } from "../lib/fs";
import { runHidden, runSecret, runVisible } from "../lib/process";
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
function parseLocalAddress(line: string): string {
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`;
if (context.config.tlsMode === "acme") {
await runVisible`grep -q '^acme:' /etc/hysteria/config.yaml`;
await runVisible`! grep -q '^tls:' /etc/hysteria/config.yaml`;
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
// с production-профилем, а не ищем подстроки.
info("verifying effective Hysteria config against HY2XS production profile");
assertHysteriaConfigMatchesProfile(await readText("/etc/hysteria/config.yaml"), context.config);
await assertEffectiveHysteriaVersion(context);
}
if (context.config.tlsMode === "file") {
await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`;
await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`;
await runVisible`grep -q 'insecure: false' /etc/hysteria/config.yaml`;
/**
* Установленный бинарник обязан совпадать с версией, замороженной в metadata
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
* расхождение там предупреждение, а не отказ.
*/
async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise<void> {
const packagedVersion = context.hysteriaVersion.trim();
if (!packagedVersion || packagedVersion === "unknown") {
return;
}
if (context.config.tlsMode === "self_signed_dev") {
await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`;
await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`;
await runVisible`grep -q 'insecure: true' /etc/hysteria/config.yaml`;
const raw = await runSecret`/usr/local/bin/hysteria version`;
const match = raw.match(/v\d+\.\d+\.\d+/);
const effective = match ? match[0] : raw.trim();
if (effective !== packagedVersion) {
const message = `effective Hysteria version ${effective} does not match package metadata ${packagedVersion}`;
if (context.mode === "install") {
throw new Error(message);
}
info(`warning: ${message}`);
}
}
+8 -1
View File
@@ -24,7 +24,10 @@ export type TlsMode = "acme" | "file" | "self_signed_dev";
export type DnsAaaaPolicy = "strict" | "warn" | "off";
export type HysteriaObfsType = "gecko" | "salamander";
export type RuntimeConfig = {
configSchemaVersion: number;
domain: string;
dnsAaaaPolicy: DnsAaaaPolicy;
publicHost: string;
@@ -52,8 +55,10 @@ export type RuntimeConfig = {
hysteriaTrafficStatsHost: string;
hysteriaTrafficStatsPort: number;
hysteriaTrafficStatsSecret: string;
hysteriaObfsType: "salamander";
hysteriaObfsType: HysteriaObfsType;
hysteriaObfsPassword: string;
hysteriaGeckoMinPacketSize: number;
hysteriaGeckoMaxPacketSize: number;
hysteriaBandwidthUp: string;
hysteriaBandwidthDown: string;
hysteriaIgnoreClientBandwidth: boolean;
@@ -72,6 +77,8 @@ export type RuntimeContext = {
packageBuildId: string;
installDate: string;
hysteriaVersion: string;
/** Как версия Hysteria была выбрана на этапе сборки пакета: latest-stable | pinned | override. */
hysteriaResolution: string;
};
export type InstallContext = RuntimeContext & {
+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. Пакет понимает только эту версию;
# установка поверх сервера со схемой v0 не поддерживается — нужна чистая установка.
HY2XS_CONFIG_SCHEMA_VERSION=2
HY2XS_IPV6_ENABLED=false
HY2XS_DOMAIN=fi.api.withen.pro
HY2XS_DNS_AAAA_POLICY=strict
@@ -27,8 +30,10 @@ HY2XS_HYSTERIA_AUTH_MODE=http
HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1
HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712
HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=__GENERATE__
# fixed in production profile: must remain salamander
HY2XS_HYSTERIA_OBFS_TYPE=salamander
# gecko | salamander. Gecko — production default HY2XS (upstream помечает его experimental).
# Salamander остаётся полностью поддержанным compatibility fallback.
# Смена значения меняет wire-совместимость: клиентам нужна новая ссылка.
HY2XS_HYSTERIA_OBFS_TYPE=gecko
HY2XS_HYSTERIA_OBFS_PASSWORD=__GENERATE__
HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps
HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps
+9 -1
View File
@@ -6,6 +6,7 @@ DEPLOY_TIMESTAMP={{LAST_APPLY_DATE}}
PACKAGE_NAME=hy2xs-install-package
PACKAGE_BUILD_ID={{PACKAGE_BUILD_ID}}
PACKAGE_VERSION={{PACKAGE_VERSION}}
HY2XS_CONFIG_SCHEMA_VERSION={{CONFIG_SCHEMA_VERSION}}
ORCH_SOURCE_STACK=bun-typescript
ORCH_BUILD_MODE=bun-compile
@@ -21,6 +22,7 @@ HY2XS_FIREWALL_STAGED_APPLY={{FIREWALL_STAGED_APPLY}}
HY2_SOURCE=official-upstream
HY2_VERSION={{HYSTERIA_VERSION}}
HY2_RESOLUTION={{HYSTERIA_RESOLUTION}}
HY2_TLS_MODE={{TLS_MODE}}
HY2_ACME_EMAIL={{ACME_EMAIL}}
HY2_TLS_CERT_PATH={{TLS_CERT_PATH}}
@@ -30,11 +32,17 @@ HY2_PORT={{HYSTERIA_PORT}}
HY2_AUTH_MODE=http
HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}
HY2_TRAFFIC_STATS_LISTEN={{HYSTERIA_API_HOST}}:{{HYSTERIA_API_PORT}}
HY2_OBFS_TYPE=salamander
HY2_OBFS_TYPE={{OBFS_TYPE}}
HY2_OBFS_PASSWORD={{HYSTERIA_OBFS_PASSWORD}}
HY2_GECKO_MIN_PACKET_SIZE={{GECKO_MIN_PACKET_SIZE}}
HY2_GECKO_MAX_PACKET_SIZE={{GECKO_MAX_PACKET_SIZE}}
HY2_BANDWIDTH_UP={{BANDWIDTH_UP}}
HY2_BANDWIDTH_DOWN={{BANDWIDTH_DOWN}}
HY2_DISABLE_LOSS_COMPENSATION={{DISABLE_LOSS_COMPENSATION}}
HY2_IGNORE_CLIENT_BANDWIDTH={{IGNORE_CLIENT_BANDWIDTH}}
HY2_CONGESTION_TYPE={{CONGESTION_TYPE}}
HY2_BBR_PROFILE={{BBR_PROFILE}}
HY2_DISABLE_STATELESS_RESET={{DISABLE_STATELESS_RESET}}
HY2_CONFIG_PATH=/etc/hysteria/config.yaml
HY2XS_ADMIN_ENABLED=true
+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}}
insecure: {{AUTH_INSECURE}}
obfs:
type: salamander
salamander:
password: "{{HYSTERIA_OBFS_PASSWORD}}"
{{OBFS_BLOCK}}
bandwidth:
up: "{{BANDWIDTH_UP}}"
down: "{{BANDWIDTH_DOWN}}"
disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}
ignoreClientBandwidth: {{IGNORE_CLIENT_BANDWIDTH}}
{{CONGESTION_BLOCK}}
trafficStats:
listen: {{HYSTERIA_API_HOST}}:{{HYSTERIA_API_PORT}}
secret: "{{HYSTERIA_API_SECRET}}"
quic:
initStreamReceiveWindow: 8388608
maxStreamReceiveWindow: 8388608
initConnReceiveWindow: 20971520
maxConnReceiveWindow: 20971520
{{QUIC_BLOCK}}
+97 -34
View File
@@ -20,7 +20,10 @@ dist/hy2xs-install-<version>.tar.gz
- [`tools/build/lib/deps.sh`](lib/deps.sh) — проверка Debian/amd64, установка build dependencies, установка Go/Bun/Node.js/pnpm.
- [`tools/build/lib/package.sh`](lib/package.sh) — сборка orchestrator, сборка HY2XS admin, создание stage directory и tar.gz архива.
- [`tools/build/lib/verify.sh`](lib/verify.sh) — проверка структуры репозитория и итогового архива.
- [`tools/build/hysteria-lock.env`](hysteria-lock.env) — pinned версия, URL и SHA256 upstream Hysteria2 binary.
- [`tools/build/lib/acceptance.sh`](lib/acceptance.sh) — acceptance-проверки production-контракта.
- [`tools/build/lib/hysteria.sh`](lib/hysteria.sh) — разрешение upstream-версии Hysteria и compatibility gate.
- [`tools/build/hysteria-lock.env`](hysteria-lock.env) — fallback-значения для офлайн-сборки (`HYSTERIA_CHANNEL=pinned`).
- [`tools/test/e2e-hysteria.sh`](../test/e2e-hysteria.sh) — end-to-end проверка с реальным клиентом Hysteria.
- [`orchestrator`](../../orchestrator) — TypeScript/Bun install-only orchestrator.
- [`apps`](../../apps) — HY2XS admin: Go backend и Vue frontend.
- [`package`](../../package) — skeleton будущего install package: `install.sh`, templates, systemd units, default config.
@@ -58,13 +61,73 @@ Windows и macOS можно использовать для редактиров
- Bun `1.3.13`;
- Node.js `20.19.0`;
- pnpm `9.15.9`.
5. Собирает install-only orchestrator в standalone binary.
6. Собирает frontend HY2XS admin.
7. Собирает backend HY2XS admin в Linux amd64 binary.
5. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`).
6. Разрешает upstream-версию Hysteria, скачивает артефакт и считает SHA-256.
7. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
8. Копирует package skeleton.
9. Записывает metadata и checksums.
10. Создаёт `dist/hy2xs-install-<version>.tar.gz`.
11. Проверяет, что архив содержит обязательные файлы.
9. Собирает install-only orchestrator в standalone binary.
10. Собирает frontend и backend HY2XS admin в Linux amd64 binary.
11. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
12. Записывает metadata и checksums.
13. Создаёт `dist/hy2xs-install-<version>.tar.gz`.
14. Проверяет архив и прогоняет acceptance-проверки.
## Версия Hysteria: разрешение и compatibility gate
Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете.
Правила разрешения:
1. канонический upstream — `HyNetworks/hysteria`;
2. принимаются только стабильные релизы, без draft и prerelease;
3. тег должен иметь вид `app/vX.Y.Z`;
4. берётся ровно один артефакт `hysteria-linux-amd64`;
5. URL используется в том виде, в каком его вернул upstream API, без пересборки строки;
6. SHA-256 считается локально от скачанного файла;
7. разрешённые значения попадают в metadata пакета.
Сравнение версий числовое, поэтому `v2.9.10` считается новее `v2.9.2`.
После разрешения обязателен compatibility gate:
```text
скачать бинарник
сверить SHA-256 и `hysteria version`
отрендерить канонический конфиг HY2XS тем же кодом, что и на target
запустить настоящий Hysteria с этим конфигом (gecko и salamander)
только после этого собирать release package
```
При несовместимости сборка останавливается:
```text
BUILD FAILED: unsupported Hysteria stable v2.13.0
```
Это осознанное решение: ошибка должна проявиться на build machine, а не на production-сервере.
Переменные:
| Переменная | По умолчанию | Назначение |
| --- | --- | --- |
| `HYSTERIA_CHANNEL` | `stable` | `stable` — разрешить последнюю стабильную через upstream API; `pinned` — офлайн-сборка по `hysteria-lock.env` |
| `HYSTERIA_VERSION_OVERRIDE` | пусто | Закрепить конкретную версию `vX.Y.Z` |
| `HYSTERIA_COMPAT_GATE` | `true` | Compatibility gate; для release-сборок обязателен |
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в `hysteria-lock.env` |
| `HYSTERIA_GATE_PORT` | `34443` | UDP-порт для временного запуска Hysteria в gate |
| `HYSTERIA_GATE_STATS_PORT` | `34712` | TCP-порт trafficStats в gate |
| `GITHUB_TOKEN` | пусто | Опционально: снимает anonymous rate limit GitHub API |
| `SKIP_TESTS` | `false` | Аварийное отключение тестов; для release-сборок недопустимо |
Обновить lock-файл под текущий upstream:
```bash
HYSTERIA_WRITE_LOCK=true ./tools/build/build.sh
```
## Важное про Bun и старые CPU
@@ -111,7 +174,7 @@ BUN_FLAVOR=x64-baseline ./tools/build/build.sh
С явной версией и build id:
```bash
PACKAGE_VERSION=0.2.1 \
PACKAGE_VERSION=1.0.0 \
BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ) \
./tools/build/build.sh
```
@@ -121,59 +184,59 @@ BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ) \
Проверка архива:
```bash
ls -lh dist/hy2xs-install-0.2.1.tar.gz
sha256sum dist/hy2xs-install-0.2.1.tar.gz | tee dist/hy2xs-install-0.2.1.tar.gz.sha256
ls -lh dist/hy2xs-install-1.0.0.tar.gz
sha256sum dist/hy2xs-install-1.0.0.tar.gz | tee dist/hy2xs-install-1.0.0.tar.gz.sha256
```
Проверка обязательных файлов:
```bash
tar -tzf dist/hy2xs-install-0.2.1.tar.gz | grep -E '^(hy2xs-install/install.sh|hy2xs-install/orchestrator/hy2xs-orchestrator|hy2xs-install/ui/hy2xs-admin/hy2xs-admin|hy2xs-install/metadata/checksums.txt)$'
tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E '^(hy2xs-install/install.sh|hy2xs-install/orchestrator/hy2xs-orchestrator|hy2xs-install/ui/hy2xs-admin/hy2xs-admin|hy2xs-install/metadata/checksums.txt)$'
```
Проверка metadata:
```bash
tar -xOzf dist/hy2xs-install-0.2.1.tar.gz hy2xs-install/metadata/package.env
tar -xOzf dist/hy2xs-install-1.0.0.tar.gz hy2xs-install/metadata/package.env
```
## When to update orchestrator/bun.lock
## Когда обновлять orchestrator/bun.lock
Do not regenerate [`orchestrator/bun.lock`](../../orchestrator/bun.lock) during production builds.
Не перегенерируйте [`orchestrator/bun.lock`](../../orchestrator/bun.lock) во время production-сборки.
Update and commit [`orchestrator/bun.lock`](../../orchestrator/bun.lock) only when:
Обновлять и коммитить [`orchestrator/bun.lock`](../../orchestrator/bun.lock) следует только когда:
- [`orchestrator/package.json`](../../orchestrator/package.json) changes;
- `BUN_REQUIRED` changes in [`tools/build/lib/deps.sh`](lib/deps.sh);
- orchestrator dependencies are intentionally upgraded.
- изменился [`orchestrator/package.json`](../../orchestrator/package.json);
- изменился `BUN_REQUIRED` в [`tools/build/lib/deps.sh`](lib/deps.sh);
- зависимости оркестратора обновляются осознанно.
Production builder always runs:
Production builder всегда выполняет:
```bash
bun install --frozen-lockfile
```
If this command fails, fix and commit the lockfile in source control. Do not remove `--frozen-lockfile`.
Если команда падает, исправьте и закоммитьте lockfile в системе контроля версий. Не убирайте `--frozen-lockfile`.
## Frontend lockfile discipline
## Дисциплина lockfile для frontend
Do not regenerate frontend lock data during routine production builds.
Не перегенерируйте frontend lock data во время обычной production-сборки.
For frontend package management:
Правила управления пакетами frontend:
- [`apps/frontend/package.json`](../../apps/frontend/package.json) declares `"packageManager": "pnpm@9.15.9"`;
- builder uses pinned pnpm `9.15.9` from [`PNPM_REQUIRED`](lib/deps.sh);
- production frontend install path is always:
- [`apps/frontend/package.json`](../../apps/frontend/package.json) объявляет `"packageManager": "pnpm@9.15.9"`;
- builder использует закреплённый pnpm `9.15.9` из [`PNPM_REQUIRED`](lib/deps.sh);
- production-путь установки frontend всегда:
```bash
pnpm install --frozen-lockfile
```
If frozen install fails, update dependencies intentionally in source control and commit lockfile changes. Do not remove `--frozen-lockfile` from build flow.
Если frozen install падает, обновите зависимости осознанно в системе контроля версий и закоммитьте изменения lockfile. Не убирайте `--frozen-lockfile` из сборочного потока.
## Полезные переменные
- `PACKAGE_VERSION=0.2.1`
- `PACKAGE_VERSION=1.0.0`
- `BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ)`
- `BUN_FLAVOR=auto|x64|x64-baseline`
- `FRONTEND_NODE_OLD_SPACE_SIZE=2048` (default memory limit for frontend build step)
@@ -186,23 +249,23 @@ If frozen install fails, update dependencies intentionally in source control and
- `NODE_ARCHIVE_SHA256=<sha256>`
- `BUN_ARCHIVE_SHA256=<sha256>`
## Frontend memory policy
## Политика памяти при сборке frontend
Builder applies a safe default Node.js heap limit for frontend build inside [`bundle_ui()`](lib/package.sh):
Builder задаёт безопасный лимит heap для Node.js внутри [`bundle_ui()`](lib/package.sh):
```bash
--max-old-space-size=2048
```
Override options:
Способы переопределения:
- adjust default value for this policy:
- изменить значение по умолчанию для этой политики:
```bash
FRONTEND_NODE_OLD_SPACE_SIZE=3072 ./tools/build/build.sh
```
- or provide full custom Node options (if `--max-old-space-size` is already set there, builder will not append another one):
- или передать полный набор собственных Node-опций (если `--max-old-space-size` там уже задан, builder не добавит второй):
```bash
NODE_OPTIONS="--max-old-space-size=3072" ./tools/build/build.sh
@@ -229,7 +292,7 @@ echo "bun_exit=$?"
rm -rf .toolchain/bun .toolchain/bun-tmp
rm -f .toolchain/downloads/bun-linux-x64-*.zip
rm -f .toolchain/downloads/bun-linux-x64-baseline-*.zip
PACKAGE_VERSION=0.2.1 ./tools/build/build.sh
PACKAGE_VERSION=1.0.0 ./tools/build/build.sh
```
Проверить shell syntax:
+14 -1
View File
@@ -10,6 +10,8 @@ BUILD_DIR="$ROOT_DIR/tools/build"
. "$BUILD_DIR/lib/deps.sh"
# shellcheck source=tools/build/lib/verify.sh
. "$BUILD_DIR/lib/verify.sh"
# shellcheck source=tools/build/lib/hysteria.sh
. "$BUILD_DIR/lib/hysteria.sh"
# shellcheck source=tools/build/lib/acceptance.sh
. "$BUILD_DIR/lib/acceptance.sh"
# shellcheck source=tools/build/lib/package.sh
@@ -24,9 +26,15 @@ main() {
ensure_build_dependencies
ensure_toolchain
PACKAGE_VERSION="${PACKAGE_VERSION:-0.1.0}"
PACKAGE_VERSION="${PACKAGE_VERSION:-1.0.0}"
BUILD_ID="${BUILD_ID:-$(date -u +%Y%m%dT%H%M%SZ)}"
log_step "Checking orchestrator contracts"
run_orchestrator_tests
log_step "Resolving upstream Hysteria and running compatibility gate"
resolve_and_verify_hysteria
log_step "Preparing package stage"
prepare_stage "$PACKAGE_VERSION" "$BUILD_ID"
@@ -36,6 +44,11 @@ main() {
log_step "Bundling HY2XS admin"
bundle_ui
# Тесты админки идут после bundle_ui: `go:embed all:dist` требует уже
# собранных frontend-ассетов, которых нет в git.
log_step "Checking HY2XS admin contracts"
run_admin_tests
log_step "Writing metadata"
write_metadata "$PACKAGE_VERSION" "$BUILD_ID"
+7 -3
View File
@@ -1,3 +1,7 @@
HYSTERIA_VERSION=v2.8.2
HYSTERIA_ARTIFACT_URL=https://github.com/apernet/hysteria/releases/download/app%2Fv2.8.2/hysteria-linux-amd64
HYSTERIA_ARTIFACT_SHA256=b11bf0fb5f84a3f5c6baff3696e899539e68af4cee868c9203cfb896784ad3b0
# Файл генерируется tools/build/lib/hysteria.sh.
# HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.
HYSTERIA_VERSION=v2.12.2
HYSTERIA_ARTIFACT_URL=https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hysteria-linux-amd64
HYSTERIA_ARTIFACT_SHA256=6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94
HYSTERIA_RESOLUTION=latest-stable
HYSTERIA_RESOLVED_AT=2026-08-27T00:00:00Z
+81
View File
@@ -30,6 +30,87 @@ run_fix20_acceptance_subset() {
log_step "Acceptance: force password change production default"
grep -q '^HY2XS_FORCE_PASSWORD_CHANGE=false$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FORCE_PASSWORD_CHANGE must default to false"
log_step "Acceptance: config schema version is declared"
grep -q '^HY2XS_CONFIG_SCHEMA_VERSION=2$' "$package_dir/config/hy2xs.env" \
|| fail "acceptance: HY2XS_CONFIG_SCHEMA_VERSION must be 2 in the packaged baseline"
log_step "Acceptance: fresh install defaults to Gecko obfuscation"
grep -q '^HY2XS_HYSTERIA_OBFS_TYPE=gecko$' "$package_dir/config/hy2xs.env" \
|| fail "acceptance: fresh installations must default to HY2XS_HYSTERIA_OBFS_TYPE=gecko"
log_step "Acceptance: obfs type is not hardcoded in the Hysteria template"
grep -q '{{OBFS_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: hysteria template must render the obfs block from the orchestrator"
! grep -Eq '^\s*type:\s*(gecko|salamander)\s*$' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: hysteria template must not hardcode an obfs type"
log_step "Acceptance: modern server baseline is present in the template"
grep -q 'disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: bandwidth.disableLossCompensation missing from hysteria template"
grep -q '{{CONGESTION_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: congestion block missing from hysteria template"
grep -q '{{QUIC_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: quic block missing from hysteria template"
log_step "Acceptance: post-install env derives obfs type from resolved context"
grep -q '^HY2_OBFS_TYPE={{OBFS_TYPE}}$' "$package_dir/templates/env/post-install.env.tpl" \
|| fail "acceptance: post-install env must render the resolved obfs type, not a second set of defaults"
! grep -Eq '^HY2_OBFS_TYPE=(gecko|salamander)$' "$package_dir/templates/env/post-install.env.tpl" \
|| fail "acceptance: post-install env must not hardcode an obfs type"
log_step "Acceptance: production defaults are declared in exactly one module"
grep -q 'DEFAULT_HYSTERIA_OBFS_TYPE' orchestrator/src/config/profile.ts \
|| fail "acceptance: the default obfs type must be declared in orchestrator/src/config/profile.ts"
local default_declarations
default_declarations="$(grep -rl 'DEFAULT_HYSTERIA_OBFS_TYPE\s*[:=]' orchestrator/src \
| grep -v 'orchestrator/src/config/profile.ts' || true)"
[ -z "$default_declarations" ] \
|| fail "acceptance: the default obfs type must not be re-declared in: $default_declarations"
log_step "Acceptance: runtime env is derived from config, not literals"
! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type"
grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config"
! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \
|| fail "acceptance: env.ts must not carry its own obfs fallback default"
log_step "Acceptance: package metadata records how the Hysteria version was chosen"
grep -q '^hysteria_resolution=' "$package_dir/metadata/package.env" \
|| fail "acceptance: metadata must record hysteria_resolution"
grep -q '^hysteria_resolved_at=' "$package_dir/metadata/package.env" \
|| fail "acceptance: metadata must record hysteria_resolved_at"
grep -q '^hysteria_compat_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: release packages must be built with the Hysteria compatibility gate enabled"
grep -Eq '^hysteria_artifact_url=https://github\.com/HyNetworks/hysteria/' "$package_dir/metadata/package.env" \
|| fail "acceptance: Hysteria artifact must come from the canonical HyNetworks upstream"
log_step "Acceptance: install-time never resolves a moving latest"
! grep -rq 'api.github.com' orchestrator/src/commands orchestrator/src/steps \
|| fail "acceptance: install-time code must not query the upstream release API"
! grep -rq 'download.hysteria.network' orchestrator/src \
|| fail "acceptance: install-time code must not use the moving latest download URL"
log_step "Acceptance: smoke verifies config semantics, not substrings"
grep -q 'assertHysteriaConfigMatchesProfile' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke must verify the effective config semantically"
log_step "Acceptance: admin export preserves unknown upstream fields and strips secrets"
grep -q 'ExportHysteria2ConfigYaml' apps/controller/config.go \
|| fail "acceptance: hysteria config export must go through the sanitizing raw-YAML path"
grep -q 'GetRawHysteria2Config' apps/service/hysteria2_export.go \
|| fail "acceptance: export must read the raw YAML instead of the typed model"
log_step "Acceptance: frontend ACME registry matches current upstream"
# Ищем именно предлагаемое значение, а не упоминание в комментарии.
! grep -q '"namedotcom"' apps/frontend/src/views/hysteria/list/index.vue \
|| fail "acceptance: namedotcom was removed upstream in Hysteria 2.11.0 and must not be offered"
local provider
for provider in cloudflare duckdns gandi godaddy namecheap njalla porkbun vultr; do
grep -q "\"${provider}\"" apps/frontend/src/views/hysteria/list/index.vue \
|| fail "acceptance: ACME DNS provider ${provider} is missing from the UI registry"
done
log_step "Acceptance: systemd unit production env"
grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing"
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env bash
set -euo pipefail
# Разрешение upstream-версии Hysteria и compatibility gate.
#
# Ключевой инвариант: "latest" вычисляется на build machine и замораживается
# в metadata пакета. Target-сервер никогда не обращается к moving "latest".
HYSTERIA_LOCK_FILE="tools/build/hysteria-lock.env"
HYSTERIA_WORK_DIR="tools/build/output/hysteria"
# stable — определить последнюю стабильную версию через upstream API (default);
# pinned — использовать закоммиченный tools/build/hysteria-lock.env без сети.
HYSTERIA_CHANNEL="${HYSTERIA_CHANNEL:-stable}"
# Явный пин конкретной версии вида v2.12.2. Побеждает автоматический выбор.
HYSTERIA_VERSION_OVERRIDE="${HYSTERIA_VERSION_OVERRIDE:-}"
# Обновлять ли tools/build/hysteria-lock.env резолвнутыми значениями.
HYSTERIA_WRITE_LOCK="${HYSTERIA_WRITE_LOCK:-false}"
# Compatibility gate обязателен для production-сборки.
HYSTERIA_COMPAT_GATE="${HYSTERIA_COMPAT_GATE:-true}"
HYSTERIA_GATE_PORT="${HYSTERIA_GATE_PORT:-34443}"
HYSTERIA_GATE_STATS_PORT="${HYSTERIA_GATE_STATS_PORT:-34712}"
HYSTERIA_GATE_TIMEOUT_SECONDS="${HYSTERIA_GATE_TIMEOUT_SECONDS:-15}"
validate_hysteria_version_format() {
local value="$1"
printf '%s' "$value" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "invalid Hysteria version format: $value (expected vX.Y.Z)"
}
validate_hysteria_sha256_format() {
local value="$1"
case "$value" in
replace-with-*|"")
fail "HYSTERIA_ARTIFACT_SHA256 must be a real release sha256"
;;
esac
printf '%s' "$value" | grep -Eq '^[a-fA-F0-9]{64}$' \
|| fail "HYSTERIA_ARTIFACT_SHA256 must be a 64-char hex SHA256, got: $value"
}
load_pinned_hysteria_lock() {
[ -f "$HYSTERIA_LOCK_FILE" ] || fail "missing Hysteria lock file: $HYSTERIA_LOCK_FILE"
# shellcheck disable=SC1090
. "$HYSTERIA_LOCK_FILE"
[ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION is required in $HYSTERIA_LOCK_FILE"
[ -n "${HYSTERIA_ARTIFACT_URL:-}" ] || fail "HYSTERIA_ARTIFACT_URL is required in $HYSTERIA_LOCK_FILE"
[ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 is required in $HYSTERIA_LOCK_FILE"
validate_hysteria_version_format "$HYSTERIA_VERSION"
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
HYSTERIA_RESOLUTION="pinned"
HYSTERIA_RESOLVED_AT="${HYSTERIA_RESOLVED_AT:-unknown}"
export HYSTERIA_VERSION HYSTERIA_ARTIFACT_URL HYSTERIA_ARTIFACT_SHA256 HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT
}
resolve_hysteria_from_upstream() {
local resolver_args=(--channel stable)
if [ -n "$HYSTERIA_VERSION_OVERRIDE" ]; then
validate_hysteria_version_format "$HYSTERIA_VERSION_OVERRIDE"
resolver_args+=(--version "$HYSTERIA_VERSION_OVERRIDE")
log_info "Resolving pinned Hysteria version override: $HYSTERIA_VERSION_OVERRIDE"
else
log_info "Resolving latest stable Hysteria release from upstream"
fi
local resolver_output
resolver_output="$(cd orchestrator && "$BUN_BIN" run tools/resolve-hysteria.ts "${resolver_args[@]}")" \
|| fail "Hysteria release resolution failed"
local key value line
HYSTERIA_VERSION=""
HYSTERIA_TAG=""
HYSTERIA_ARTIFACT_URL=""
HYSTERIA_PUBLISHED_AT=""
HYSTERIA_RESOLUTION=""
while IFS= read -r line; do
[ -n "$line" ] || continue
key="${line%%=*}"
value="${line#*=}"
case "$key" in
HYSTERIA_VERSION) HYSTERIA_VERSION="$value" ;;
HYSTERIA_TAG) HYSTERIA_TAG="$value" ;;
HYSTERIA_ARTIFACT_URL) HYSTERIA_ARTIFACT_URL="$value" ;;
HYSTERIA_PUBLISHED_AT) HYSTERIA_PUBLISHED_AT="$value" ;;
HYSTERIA_RESOLUTION) HYSTERIA_RESOLUTION="$value" ;;
*) fail "unexpected resolver output line: $line" ;;
esac
done <<<"$resolver_output"
[ -n "$HYSTERIA_VERSION" ] || fail "resolver did not return HYSTERIA_VERSION"
[ -n "$HYSTERIA_ARTIFACT_URL" ] || fail "resolver did not return HYSTERIA_ARTIFACT_URL"
validate_hysteria_version_format "$HYSTERIA_VERSION"
HYSTERIA_RESOLVED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
log_info "Resolved Hysteria $HYSTERIA_VERSION (tag ${HYSTERIA_TAG:-unknown}, published ${HYSTERIA_PUBLISHED_AT:-unknown})"
export HYSTERIA_VERSION HYSTERIA_TAG HYSTERIA_ARTIFACT_URL HYSTERIA_PUBLISHED_AT
export HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT
}
download_hysteria_artifact() {
mkdir -p "$HYSTERIA_WORK_DIR"
HYSTERIA_BINARY="$HYSTERIA_WORK_DIR/hysteria-linux-amd64"
log_info "Downloading $HYSTERIA_ARTIFACT_URL"
rm -f "$HYSTERIA_BINARY"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \
"$HYSTERIA_ARTIFACT_URL" -o "$HYSTERIA_BINARY" \
|| fail "failed to download Hysteria artifact: $HYSTERIA_ARTIFACT_URL"
[ -s "$HYSTERIA_BINARY" ] || fail "downloaded Hysteria artifact is empty"
local actual_sha
actual_sha="$(sha256sum "$HYSTERIA_BINARY" | awk '{print $1}')"
if [ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ]; then
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
[ "$actual_sha" = "$HYSTERIA_ARTIFACT_SHA256" ] \
|| fail "Hysteria artifact sha256 mismatch: expected $HYSTERIA_ARTIFACT_SHA256, got $actual_sha"
else
HYSTERIA_ARTIFACT_SHA256="$actual_sha"
log_info "Computed Hysteria artifact sha256: $HYSTERIA_ARTIFACT_SHA256"
fi
export HYSTERIA_ARTIFACT_SHA256 HYSTERIA_BINARY
chmod 0755 "$HYSTERIA_BINARY"
local reported_version
reported_version="$("$HYSTERIA_BINARY" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
[ -n "$reported_version" ] || fail "unable to read version from downloaded Hysteria binary"
[ "$reported_version" = "$HYSTERIA_VERSION" ] \
|| fail "downloaded Hysteria binary reports $reported_version, expected $HYSTERIA_VERSION"
log_info "Verified Hysteria binary: $HYSTERIA_VERSION ($HYSTERIA_ARTIFACT_SHA256)"
}
assert_gate_port_is_free() {
local port="$1"
local proto_flag="$2"
if command -v ss >/dev/null 2>&1; then
if ss -H "$proto_flag" 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
fail "compatibility gate port $port is already in use; override HYSTERIA_GATE_PORT/HYSTERIA_GATE_STATS_PORT"
fi
fi
}
gate_generate_self_signed_cert() {
local dir="$1"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj /CN=hy2xs-compat-gate \
-keyout "$dir/gate.key" -out "$dir/gate.crt" >/dev/null 2>&1 \
|| fail "compatibility gate could not generate a self-signed certificate"
}
gate_run_single_profile() {
local obfs_type="$1"
local gate_dir="$2"
local config_path="$gate_dir/config-$obfs_type.yaml"
local log_path="$gate_dir/hysteria-$obfs_type.log"
log_info "Compatibility gate: rendering canonical config (obfs=$obfs_type)"
(
cd orchestrator
"$BUN_BIN" run tools/render-canonical-config.ts \
--package-dir ../package \
--obfs "$obfs_type" \
--tls-mode file \
--cert "$gate_dir/gate.crt" \
--key "$gate_dir/gate.key" \
--port "$HYSTERIA_GATE_PORT" \
--traffic-stats-port "$HYSTERIA_GATE_STATS_PORT" \
--out "$config_path"
) || fail "BUILD FAILED: HY2XS could not render canonical config for obfs=$obfs_type"
grep -q "type: $obfs_type" "$config_path" \
|| fail "compatibility gate rendered a config without obfs type $obfs_type"
log_info "Compatibility gate: starting Hysteria $HYSTERIA_VERSION with generated config (obfs=$obfs_type)"
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BINARY" server -c "$config_path" >"$log_path" 2>&1 &
local gate_pid=$!
local ready="false"
local waited=0
while [ "$waited" -lt "$HYSTERIA_GATE_TIMEOUT_SECONDS" ]; do
if ! kill -0 "$gate_pid" 2>/dev/null; then
break
fi
if command -v ss >/dev/null 2>&1 \
&& ss -H -lun 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${HYSTERIA_GATE_PORT}$"; then
ready="true"
break
fi
sleep 1
waited=$((waited + 1))
done
if [ "$ready" != "true" ]; then
kill "$gate_pid" 2>/dev/null || true
wait "$gate_pid" 2>/dev/null || true
log_info "--- Hysteria compatibility gate output (obfs=$obfs_type) ---"
cat "$log_path" >&2 || true
fail "BUILD FAILED: unsupported Hysteria stable $HYSTERIA_VERSION (obfs=$obfs_type config was rejected)"
fi
kill "$gate_pid" 2>/dev/null || true
wait "$gate_pid" 2>/dev/null || true
log_info "Compatibility gate passed for obfs=$obfs_type"
}
run_hysteria_compatibility_gate() {
if [ "$HYSTERIA_COMPAT_GATE" != "true" ]; then
log_info "WARNING: Hysteria compatibility gate is disabled (HYSTERIA_COMPAT_GATE=$HYSTERIA_COMPAT_GATE)"
return 0
fi
require_tool openssl
[ -x "${HYSTERIA_BINARY:-}" ] || fail "compatibility gate requires a verified Hysteria binary"
assert_gate_port_is_free "$HYSTERIA_GATE_PORT" "-lun"
assert_gate_port_is_free "$HYSTERIA_GATE_STATS_PORT" "-ltn"
local gate_dir
gate_dir="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '$gate_dir'" RETURN
gate_generate_self_signed_cert "$gate_dir"
gate_run_single_profile "gecko" "$gate_dir"
gate_run_single_profile "salamander" "$gate_dir"
log_info "Compatibility gate: Hysteria $HYSTERIA_VERSION accepts the HY2XS production config"
}
write_hysteria_lock_file() {
if [ "$HYSTERIA_WRITE_LOCK" != "true" ]; then
return 0
fi
log_info "Updating $HYSTERIA_LOCK_FILE with resolved values"
cat >"$HYSTERIA_LOCK_FILE" <<EOF
# Файл генерируется tools/build/lib/hysteria.sh.
# HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.
HYSTERIA_VERSION=$HYSTERIA_VERSION
HYSTERIA_ARTIFACT_URL=$HYSTERIA_ARTIFACT_URL
HYSTERIA_ARTIFACT_SHA256=$HYSTERIA_ARTIFACT_SHA256
HYSTERIA_RESOLUTION=$HYSTERIA_RESOLUTION
HYSTERIA_RESOLVED_AT=$HYSTERIA_RESOLVED_AT
EOF
}
resolve_and_verify_hysteria() {
case "$HYSTERIA_CHANNEL" in
stable)
resolve_hysteria_from_upstream
# sha256 считается локально от скачанного артефакта
HYSTERIA_ARTIFACT_SHA256=""
;;
pinned)
log_info "Using pinned Hysteria lock file (offline): $HYSTERIA_LOCK_FILE"
load_pinned_hysteria_lock
;;
*)
fail "unsupported HYSTERIA_CHANNEL: $HYSTERIA_CHANNEL (use stable or pinned)"
;;
esac
download_hysteria_artifact
run_hysteria_compatibility_gate
write_hysteria_lock_file
}
+44 -14
View File
@@ -3,7 +3,6 @@ set -euo pipefail
STAGE_DIR="tools/build/output/hy2xs-install"
ADMIN_BUILD_DIR="tools/build/output/hy2xs-admin-build"
HYSTERIA_LOCK_FILE="tools/build/hysteria-lock.env"
prepare_stage() {
local version="$1"
@@ -27,6 +26,38 @@ prepare_stage() {
printf '%s\n' "$build_id" >"$STAGE_DIR/metadata/package.build_id"
}
run_orchestrator_tests() {
if [ "${SKIP_TESTS:-false}" = "true" ]; then
log_info "WARNING: orchestrator tests skipped (SKIP_TESTS=true)"
return 0
fi
(
cd orchestrator
"$BUN_BIN" install --frozen-lockfile
"$BUN_BIN" x tsc --noEmit
"$BUN_BIN" test
) || fail "orchestrator contract tests failed"
}
run_admin_tests() {
if [ "${SKIP_TESTS:-false}" = "true" ]; then
log_info "WARNING: HY2XS admin tests skipped (SKIP_TESTS=true)"
return 0
fi
# `go:embed all:dist` требует собранных frontend-ассетов, поэтому эта
# функция должна вызываться только после bundle_ui.
[ -d "${UI_SRC:-apps}/frontend/dist" ] \
|| fail "run_admin_tests must run after bundle_ui: ${UI_SRC:-apps}/frontend/dist is missing"
(
cd "${UI_SRC:-apps}"
GOTOOLCHAIN=local "$GO_BIN" vet ./...
GOTOOLCHAIN=local "$GO_BIN" test ./...
) || fail "HY2XS admin contract tests failed"
}
build_orchestrator() {
local bun_compile_target="${BUN_COMPILE_TARGET:-bun-linux-x64}"
@@ -88,19 +119,13 @@ write_metadata() {
dirty_tree="true"
fi
[ -f "$HYSTERIA_LOCK_FILE" ] || fail "missing Hysteria lock file: $HYSTERIA_LOCK_FILE"
# shellcheck disable=SC1090
. "$HYSTERIA_LOCK_FILE"
[ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION is required in $HYSTERIA_LOCK_FILE"
[ -n "${HYSTERIA_ARTIFACT_URL:-}" ] || fail "HYSTERIA_ARTIFACT_URL is required in $HYSTERIA_LOCK_FILE"
[ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 is required in $HYSTERIA_LOCK_FILE"
case "$HYSTERIA_ARTIFACT_SHA256" in
replace-with-*|"")
fail "HYSTERIA_ARTIFACT_SHA256 must be a real release sha256"
;;
esac
printf '%s' "$HYSTERIA_ARTIFACT_SHA256" | grep -Eq '^[a-fA-F0-9]{64}$' \
|| fail "HYSTERIA_ARTIFACT_SHA256 must be a 64-char hex SHA256"
# Значения приходят из resolve_and_verify_hysteria: они уже проверены
# по SHA-256, по `hysteria version` и по compatibility gate.
[ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION was not resolved before write_metadata"
[ -n "${HYSTERIA_ARTIFACT_URL:-}" ] || fail "HYSTERIA_ARTIFACT_URL was not resolved before write_metadata"
[ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 was not resolved before write_metadata"
validate_hysteria_version_format "$HYSTERIA_VERSION"
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
{
printf 'name=HY2XS\n'
@@ -127,11 +152,16 @@ write_metadata() {
printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION"
printf 'hysteria_artifact_url=%s\n' "$HYSTERIA_ARTIFACT_URL"
printf 'hysteria_artifact_sha256=%s\n' "$HYSTERIA_ARTIFACT_SHA256"
printf 'hysteria_channel=%s\n' "${HYSTERIA_CHANNEL:-unknown}"
printf 'hysteria_resolution=%s\n' "${HYSTERIA_RESOLUTION:-unknown}"
printf 'hysteria_resolved_at=%s\n' "${HYSTERIA_RESOLVED_AT:-unknown}"
printf 'hysteria_compat_gate=%s\n' "${HYSTERIA_COMPAT_GATE:-unknown}"
} >"$STAGE_DIR/metadata/package.env"
printf '%s\n' "$HYSTERIA_VERSION" >"$STAGE_DIR/metadata/hysteria.version"
printf '%s\n' "$HYSTERIA_ARTIFACT_URL" >"$STAGE_DIR/metadata/hysteria.url"
printf '%s\n' "$HYSTERIA_ARTIFACT_SHA256" >"$STAGE_DIR/metadata/hysteria.sha256"
printf '%s\n' "${HYSTERIA_RESOLUTION:-unknown}" >"$STAGE_DIR/metadata/hysteria.resolution"
(
cd "$STAGE_DIR"
+27
View File
@@ -54,6 +54,10 @@ verify_archive() {
printf '%s\n' "$listing" | grep -q '^hy2xs-install/config/hy2xs.env$' || fail "archive missing canonical runtime config"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/templates/env/post-install.env.tpl$' || fail "archive missing post-install env template"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/checksums.txt$' || fail "archive missing checksums"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.version$' || fail "archive missing pinned Hysteria version"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.url$' || fail "archive missing pinned Hysteria artifact url"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.sha256$' || fail "archive missing pinned Hysteria sha256"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.resolution$' || fail "archive missing Hysteria resolution marker"
local license_text
license_text="$(tar -xOzf "$archive" hy2xs-install/LICENSE)"
@@ -70,6 +74,29 @@ verify_archive() {
local hysteria_tpl
hysteria_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/hysteria/config.yaml.tpl)"
printf '%s\n' "$hysteria_tpl" | grep -q '/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}' || fail "hysteria auth template must include machine access_token"
printf '%s\n' "$hysteria_tpl" | grep -q '{{OBFS_BLOCK}}' || fail "hysteria template must render a typed obfs block"
# Замороженная версия обязана совпадать во всех местах пакета.
local pinned_version pinned_sha pinned_url meta_env
pinned_version="$(tar -xOzf "$archive" hy2xs-install/metadata/hysteria.version | tr -d '\n')"
pinned_sha="$(tar -xOzf "$archive" hy2xs-install/metadata/hysteria.sha256 | tr -d '\n')"
pinned_url="$(tar -xOzf "$archive" hy2xs-install/metadata/hysteria.url | tr -d '\n')"
meta_env="$(tar -xOzf "$archive" hy2xs-install/metadata/package.env)"
printf '%s' "$pinned_version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "packaged Hysteria version is not a concrete vX.Y.Z: $pinned_version"
printf '%s' "$pinned_sha" | grep -Eq '^[a-f0-9]{64}$' \
|| fail "packaged Hysteria sha256 is not a 64-char hex digest"
printf '%s' "$pinned_url" | grep -Fq "$pinned_version" \
|| fail "packaged Hysteria artifact url does not point at the pinned version: $pinned_url"
if printf '%s' "$pinned_url" | grep -q 'latest'; then
fail "packaged Hysteria artifact url must be immutable, not a moving latest: $pinned_url"
fi
printf '%s\n' "$meta_env" | grep -q "^hysteria_version=${pinned_version}$" \
|| fail "metadata package.env disagrees with metadata/hysteria.version"
printf '%s\n' "$meta_env" | grep -q "^hysteria_artifact_sha256=${pinned_sha}$" \
|| fail "metadata package.env disagrees with metadata/hysteria.sha256"
local post_install_tpl
post_install_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/env/post-install.env.tpl)"
+527
View File
@@ -0,0 +1,527 @@
#!/usr/bin/env bash
set -euo pipefail
# HY2XS end-to-end проверка связки «сервер + реальный клиент Hysteria».
#
# Что проверяется:
# 1. сервер принимает сгенерированный HY2XS конфиг и стартует;
# 2. TLS handshake;
# 3. handshake с обфускацией (gecko и salamander);
# 4. HTTP auth HY2XS: допуск разрешённого пира и отказ неразрешённому;
# 5. клиент подключается ИМЕННО по сгенерированному hysteria2:// URI;
# 6. TCP forwarding через туннель;
# 7. UDP forwarding через туннель;
# 8. trafficStats API: валидный и невалидный secret;
# 9. per-peer accounting;
# 10. перезапуск сервера и быстрое переподключение клиента.
#
# Скрипт самодостаточен: поднимает mock HY2XS auth endpoint, поэтому не требует
# установленной админки и базы. Рассчитан на Debian 13 amd64.
#
# ./tools/test/e2e-hysteria.sh
# HYSTERIA_BIN=/tmp/hysteria ./tools/test/e2e-hysteria.sh --obfs salamander
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
OBFS_TYPES=()
HYSTERIA_BIN="${HYSTERIA_BIN:-/usr/local/bin/hysteria}"
BUN_BIN="${BUN_BIN:-bun}"
SERVER_PORT="${E2E_SERVER_PORT:-34643}"
STATS_PORT="${E2E_STATS_PORT:-34644}"
AUTH_PORT="${E2E_AUTH_PORT:-34645}"
ORIGIN_TCP_PORT="${E2E_ORIGIN_TCP_PORT:-34646}"
ORIGIN_UDP_PORT="${E2E_ORIGIN_UDP_PORT:-34647}"
FORWARD_TCP_PORT="${E2E_FORWARD_TCP_PORT:-34648}"
FORWARD_UDP_PORT="${E2E_FORWARD_UDP_PORT:-34649}"
VALID_SECRET="hy2xs-e2e-valid-peer-secret"
INVALID_SECRET="hy2xs-e2e-invalid-peer-secret"
STATS_SECRET="hy2xs-e2e-traffic-secret"
OBFS_PASSWORD="hy2xs-e2e-obfs-password"
SNI_NAME="hy2xs-e2e.local"
WORK_DIR=""
SERVER_PID=""
CLIENT_PID=""
AUTH_PID=""
ORIGIN_PID=""
log() { printf '[hy2xs-e2e] %s\n' "$*"; }
step() { printf '\n[hy2xs-e2e] ==> %s\n' "$*"; }
pass() { printf '[hy2xs-e2e] ok: %s\n' "$*"; }
die() { printf '[hy2xs-e2e] FAIL: %s\n' "$*" >&2; exit 1; }
usage() {
cat >&2 <<'EOF'
Usage: tools/test/e2e-hysteria.sh [--obfs gecko|salamander|all]
Environment:
HYSTERIA_BIN path to the Hysteria binary under test (default /usr/local/bin/hysteria)
BUN_BIN path to bun (default: bun from PATH)
EOF
exit 2
}
parse_args() {
local requested="all"
while [ $# -gt 0 ]; do
case "$1" in
--obfs)
requested="${2:-}"
shift 2 || true
;;
-h|--help) usage ;;
*) die "unknown argument: $1" ;;
esac
done
case "$requested" in
all) OBFS_TYPES=(gecko salamander) ;;
gecko) OBFS_TYPES=(gecko) ;;
salamander) OBFS_TYPES=(salamander) ;;
*) die "unsupported --obfs value: $requested" ;;
esac
}
require_tool() {
command -v "$1" >/dev/null 2>&1 || die "required tool not found: $1"
}
preflight() {
[ -x "$HYSTERIA_BIN" ] || die "Hysteria binary not found or not executable: $HYSTERIA_BIN (set HYSTERIA_BIN)"
require_tool openssl
require_tool curl
require_tool ss
command -v "$BUN_BIN" >/dev/null 2>&1 || die "bun not found (set BUN_BIN)"
local port
for port in "$SERVER_PORT" "$STATS_PORT" "$AUTH_PORT" \
"$ORIGIN_TCP_PORT" "$ORIGIN_UDP_PORT" "$FORWARD_TCP_PORT" "$FORWARD_UDP_PORT"; do
if ss -H -lantu 2>/dev/null | awk '{print $5}' | grep -Eq "[:.]${port}\$"; then
die "port $port is already in use"
fi
done
log "Hysteria under test: $("$HYSTERIA_BIN" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
}
kill_pid() {
local name="$1"
local pid="${!name}"
[ -n "$pid" ] || return 0
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
printf -v "$name" '%s' ""
}
cleanup() {
kill_pid CLIENT_PID
kill_pid SERVER_PID
kill_pid ORIGIN_PID
kill_pid AUTH_PID
[ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR" || true
}
trap cleanup EXIT
wait_for_port() {
local port="$1" proto_flag="$2" label="$3" attempts="${4:-30}"
local i=0
while [ "$i" -lt "$attempts" ]; do
if ss -H "$proto_flag" 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}\$"; then
return 0
fi
sleep 1
i=$((i + 1))
done
die "$label did not start listening on port $port"
}
# Mock HY2XS auth endpoint повторяет контракт /hui/hysteria2/auth:
# проверку machine access_token и отказ неразрешённому секрету.
start_auth_endpoint() {
cat >"$WORK_DIR/auth-server.ts" <<EOF
const validSecret = ${VALID_SECRET@Q};
const machineToken = ${STATS_SECRET@Q};
Bun.serve({
hostname: "127.0.0.1",
port: ${AUTH_PORT},
async fetch(request) {
const url = new URL(request.url);
if (url.searchParams.get("access_token") !== machineToken) {
return new Response("forbidden", { status: 403 });
}
const body = await request.json().catch(() => null);
if (!body || typeof body.auth !== "string" || typeof body.tx !== "number") {
return Response.json({ ok: false }, { status: 400 });
}
if (body.auth !== validSecret) {
return Response.json({ ok: false });
}
return Response.json({ ok: true, id: "e2e-peer" });
}
});
EOF
"$BUN_BIN" run "$WORK_DIR/auth-server.ts" >"$WORK_DIR/auth.log" 2>&1 &
AUTH_PID=$!
wait_for_port "$AUTH_PORT" "-ltn" "mock auth endpoint"
pass "mock HY2XS auth endpoint is up"
}
# TCP- и UDP-origin: отвечают фиксированной строкой, чтобы можно было
# однозначно отличить рабочий туннель от неответа.
start_origins() {
cat >"$WORK_DIR/origins.ts" <<EOF
Bun.listen({
hostname: "127.0.0.1",
port: ${ORIGIN_TCP_PORT},
socket: {
open(socket) {
socket.write("HY2XS-TCP-OK\n");
socket.flush();
socket.end();
},
data() {},
error() {}
}
});
await Bun.udpSocket({
hostname: "127.0.0.1",
port: ${ORIGIN_UDP_PORT},
socket: {
data(socket, _buf, port, address) {
socket.send("HY2XS-UDP-OK", port, address);
}
}
});
EOF
"$BUN_BIN" run "$WORK_DIR/origins.ts" >"$WORK_DIR/origins.log" 2>&1 &
ORIGIN_PID=$!
wait_for_port "$ORIGIN_TCP_PORT" "-ltn" "tcp origin"
wait_for_port "$ORIGIN_UDP_PORT" "-lun" "udp origin"
pass "TCP and UDP origins are up"
}
render_server_config() {
local obfs_type="$1"
(
cd orchestrator
"$BUN_BIN" run tools/render-canonical-config.ts \
--package-dir ../package \
--obfs "$obfs_type" \
--tls-mode file \
--cert "$WORK_DIR/server.crt" \
--key "$WORK_DIR/server.key" \
--port "$SERVER_PORT" \
--traffic-stats-port "$STATS_PORT" \
--out "$WORK_DIR/server.yaml"
) || die "could not render canonical server config for obfs=$obfs_type"
# Подставляем e2e-секреты и локальный auth endpoint, не трогая структуру
# конфига: проверяется тот же YAML, который получает production.
"$BUN_BIN" -e '
const [path, obfs, stats, authPort] = process.argv.slice(1);
let text = require("node:fs").readFileSync(path, "utf8");
text = text.split("hy2xs-compat-gate-obfs-password").join(obfs);
text = text.split("hy2xs-compat-gate-traffic-secret").join(stats);
text = text.replace(/url: http:\/\/127\.0\.0\.1:\d+\//, `url: http://127.0.0.1:${authPort}/`);
require("node:fs").writeFileSync(path, text);
' "$WORK_DIR/server.yaml" "$OBFS_PASSWORD" "$STATS_SECRET" "$AUTH_PORT" \
|| die "could not inject e2e secrets into the server config"
grep -q "type: $obfs_type" "$WORK_DIR/server.yaml" \
|| die "rendered server config does not use obfs type $obfs_type"
grep -q "127.0.0.1:${AUTH_PORT}/hui/hysteria2/auth?access_token=" "$WORK_DIR/server.yaml" \
|| die "server config lost the HY2XS machine auth token"
}
start_server() {
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" server -c "$WORK_DIR/server.yaml" \
>>"$WORK_DIR/server.log" 2>&1 &
SERVER_PID=$!
local i=0
while [ "$i" -lt 20 ]; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
log "--- server log ---"
cat "$WORK_DIR/server.log" >&2 || true
die "server exited while starting"
fi
if ss -H -lun 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${SERVER_PORT}\$"; then
return 0
fi
sleep 1
i=$((i + 1))
done
log "--- server log ---"
cat "$WORK_DIR/server.log" >&2 || true
die "server did not start listening on UDP $SERVER_PORT"
}
urlencode() {
"$BUN_BIN" -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1"
}
# Ссылка строится ровно по официальной URI-схеме, как её генерирует админка.
build_share_uri() {
local obfs_type="$1"
local secret="$2"
printf 'hysteria2://%s@127.0.0.1:%s/?obfs=%s&obfs-password=%s&sni=%s&insecure=1' \
"$(urlencode "$secret")" "$SERVER_PORT" "$obfs_type" "$(urlencode "$OBFS_PASSWORD")" "$SNI_NAME"
}
# Клиент конфигурируется ИЗ share URI, а не собирается независимо: именно это
# ловит расхождение между генератором ссылок и реальным сервером.
write_client_config_from_uri() {
cat >"$WORK_DIR/client.yaml" <<EOF
server: $1
tcpForwarding:
- listen: 127.0.0.1:${FORWARD_TCP_PORT}
remote: 127.0.0.1:${ORIGIN_TCP_PORT}
udpForwarding:
- listen: 127.0.0.1:${FORWARD_UDP_PORT}
remote: 127.0.0.1:${ORIGIN_UDP_PORT}
timeout: 20s
EOF
}
start_client() {
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" client -c "$WORK_DIR/client.yaml" \
>>"$WORK_DIR/client.log" 2>&1 &
CLIENT_PID=$!
local i=0
while [ "$i" -lt 25 ]; do
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
log "--- client log ---"
cat "$WORK_DIR/client.log" >&2 || true
die "client exited while connecting via the generated share URI"
fi
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${FORWARD_TCP_PORT}\$"; then
return 0
fi
sleep 1
i=$((i + 1))
done
log "--- client log ---"
cat "$WORK_DIR/client.log" >&2 || true
die "client did not open its forwarding listeners"
}
probe_tcp() {
"$BUN_BIN" -e '
const port = Number(process.argv[1]);
const chunks: string[] = [];
const socket = await Bun.connect({
hostname: "127.0.0.1",
port,
socket: {
data(_s, chunk) { chunks.push(new TextDecoder().decode(chunk)); },
error() {}
}
});
await Bun.sleep(Number(process.argv[2]));
socket.end();
process.stdout.write(chunks.join(""));
' "$FORWARD_TCP_PORT" "3000" 2>/dev/null || true
}
probe_udp() {
"$BUN_BIN" -e '
const port = Number(process.argv[1]);
const answer = await new Promise<string>(async (resolve) => {
const timer = setTimeout(() => resolve(""), Number(process.argv[2]));
const socket = await Bun.udpSocket({
hostname: "127.0.0.1",
port: 0,
socket: {
data(_s, buf) {
clearTimeout(timer);
resolve(new TextDecoder().decode(buf));
}
}
});
socket.send("ping", port, "127.0.0.1");
});
process.stdout.write(answer);
' "$FORWARD_UDP_PORT" "5000" 2>/dev/null || true
}
assert_tcp_forwarding() {
local i=0
while [ "$i" -lt 10 ]; do
case "$(probe_tcp)" in
*HY2XS-TCP-OK*)
pass "TCP forwarding works through the tunnel"
return 0
;;
esac
sleep 1
i=$((i + 1))
done
die "TCP forwarding through the tunnel failed"
}
assert_udp_forwarding() {
local i=0
while [ "$i" -lt 10 ]; do
case "$(probe_udp)" in
*HY2XS-UDP-OK*)
pass "UDP forwarding works through the tunnel"
return 0
;;
esac
sleep 1
i=$((i + 1))
done
die "UDP forwarding through the tunnel failed"
}
assert_traffic_stats() {
local code
code="$(curl --silent --max-time 5 -o "$WORK_DIR/online.json" -w '%{http_code}' \
-H "Authorization: ${STATS_SECRET}" "http://127.0.0.1:${STATS_PORT}/online" || true)"
[ "$code" = "200" ] || die "trafficStats /online returned $code for a valid secret"
local denied
denied="$(curl --silent --max-time 5 -o /dev/null -w '%{http_code}' \
-H "Authorization: definitely-not-the-secret" "http://127.0.0.1:${STATS_PORT}/online" || true)"
case "$denied" in
401|403) ;;
*) die "trafficStats /online returned $denied for an invalid secret, expected 401/403" ;;
esac
local traffic_code
traffic_code="$(curl --silent --max-time 5 -o "$WORK_DIR/traffic.json" -w '%{http_code}' \
-H "Authorization: ${STATS_SECRET}" "http://127.0.0.1:${STATS_PORT}/traffic" || true)"
[ "$traffic_code" = "200" ] || die "trafficStats /traffic returned $traffic_code"
grep -q 'e2e-peer' "$WORK_DIR/traffic.json" \
|| die "per-peer accounting does not contain the authenticated peer: $(cat "$WORK_DIR/traffic.json")"
pass "trafficStats API and per-peer accounting work"
}
assert_reconnect_after_restart() {
kill_pid SERVER_PID
start_server
local i=0
while [ "$i" -lt 25 ]; do
case "$(probe_tcp)" in
*HY2XS-TCP-OK*)
pass "client recovered the tunnel after a server restart (${i}s)"
return 0
;;
esac
sleep 1
i=$((i + 1))
done
die "client did not recover the tunnel after a server restart"
}
assert_auth_rejects_unknown_peer() {
local obfs_type="$1"
kill_pid CLIENT_PID
write_client_config_from_uri "$(build_share_uri "$obfs_type" "$INVALID_SECRET")"
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" client -c "$WORK_DIR/client.yaml" \
>"$WORK_DIR/client-denied.log" 2>&1 &
CLIENT_PID=$!
# Клиент либо завершается, либо не может пробросить трафик.
local i=0
while [ "$i" -lt 15 ]; do
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
kill_pid CLIENT_PID
pass "unauthorized peer is rejected by HY2XS HTTP auth (client exited)"
return 0
fi
if grep -Eqi 'auth|denied|reject|unauthor' "$WORK_DIR/client-denied.log"; then
kill_pid CLIENT_PID
pass "unauthorized peer is rejected by HY2XS HTTP auth (auth failure reported)"
return 0
fi
sleep 1
i=$((i + 1))
done
local leaked
leaked="$(probe_tcp)"
kill_pid CLIENT_PID
case "$leaked" in
*HY2XS-TCP-OK*)
die "server accepted a peer that HY2XS auth must reject"
;;
esac
pass "unauthorized peer cannot pass traffic"
}
run_profile() {
local obfs_type="$1"
step "profile: obfs=$obfs_type"
: >"$WORK_DIR/server.log"
: >"$WORK_DIR/client.log"
render_server_config "$obfs_type"
start_server
pass "server accepted the HY2XS config and started (TLS + $obfs_type)"
local uri
uri="$(build_share_uri "$obfs_type" "$VALID_SECRET")"
log "share URI under test: ${uri//$OBFS_PASSWORD/<obfs-password>}"
write_client_config_from_uri "$uri"
start_client
pass "client connected using the generated hysteria2:// URI (TLS + $obfs_type handshake)"
assert_tcp_forwarding
assert_udp_forwarding
assert_traffic_stats
assert_reconnect_after_restart
assert_auth_rejects_unknown_peer "$obfs_type"
kill_pid CLIENT_PID
kill_pid SERVER_PID
pass "profile obfs=$obfs_type passed"
}
main() {
parse_args "$@"
preflight
WORK_DIR="$(mktemp -d)"
log "work dir: $WORK_DIR"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj "/CN=${SNI_NAME}" \
-addext "subjectAltName=DNS:${SNI_NAME}" \
-keyout "$WORK_DIR/server.key" -out "$WORK_DIR/server.crt" >/dev/null 2>&1 \
|| die "could not generate the e2e certificate"
start_auth_endpoint
start_origins
local obfs_type
for obfs_type in "${OBFS_TYPES[@]}"; do
run_profile "$obfs_type"
done
step "all HY2XS end-to-end checks passed"
}
main "$@"