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:
@@ -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
@@ -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
|
||||
@@ -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 можно использовать для разработки
|
||||
- не включает IPv6‑production baseline;
|
||||
- не настраивает `sshd` автоматически;
|
||||
- не предоставляет полноценный uninstall/update framework;
|
||||
- не обновляет Hysteria2 на уже работающем сервере: `reconfigure` намеренно не является Hysteria updater;
|
||||
- не мигрирует установки `0.x` на `1.0.0` — переход выполняется чистой установкой, см. [CHANGELOG](CHANGELOG.md);
|
||||
- не выполняет сложную миграцию старых неизвестных состояний сервера;
|
||||
- не реализует Telegram‑бота, port hopping и универсальный access‑delivery workflow;
|
||||
- не предназначен для установки поверх давно используемого сервера с неизвестными firewall/systemd‑правками.
|
||||
@@ -147,10 +151,74 @@ hy2xs-install/
|
||||
| TrafficStats Hysteria2 | `127.0.0.1:36712` |
|
||||
| Firewall mode | `takeover` в packaged baseline |
|
||||
| Hysteria2 auth | `http` через локальный HY2XS admin |
|
||||
| Hysteria2 obfs | `salamander` |
|
||||
| Hysteria2 obfs | `gecko` (512/1200); `salamander` доступен как режим совместимости |
|
||||
| Congestion fallback | `bbr`, профиль `standard` |
|
||||
| QUIC stateless reset | включён |
|
||||
|
||||
Важно: `HY2XS_SSH_PORT` нужен HY2XS для nftables‑правил и проверки доступности SSH‑порта. Сам `sshd` проект не перенастраивает. SSH на `2323` и вход только по ключу нужно настроить до запуска `./install.sh`.
|
||||
|
||||
## Версионная политика Hysteria2
|
||||
|
||||
HY2XS **не привязан к конкретному номеру версии Hysteria**.
|
||||
|
||||
> Источник по умолчанию берёт последний стабильный релиз Hysteria, доступный на момент сборки пакета. Разрешённая версия, URL артефакта и контрольная сумма замораживаются в получившемся install‑пакете.
|
||||
|
||||
Как это работает:
|
||||
|
||||
```text
|
||||
build machine target server
|
||||
───────────── ─────────────
|
||||
определить последнюю стабильную ─┐
|
||||
скачать артефакт, посчитать SHA-256 │
|
||||
проверить, что бинарник принимает ├─► release‑пакет ──► скачать ровно
|
||||
канонический конфиг HY2XS │ version + url этот артефакт,
|
||||
заморозить version/url/sha256 ─┘ + sha256 сверить SHA-256
|
||||
и `hysteria version`
|
||||
```
|
||||
|
||||
Что это даёт:
|
||||
|
||||
- новая установка получает актуальную Hysteria без ручного обновления version lock;
|
||||
- если между сборкой пакета и его установкой выйдет новая версия, **содержимое установки не изменится**;
|
||||
- повторная установка старого пакета поставит ту же версию, что и в день сборки;
|
||||
- несовместимый upstream ломает сборку, а не сервер оператора.
|
||||
|
||||
Переопределения при сборке:
|
||||
|
||||
```bash
|
||||
# по умолчанию: последняя стабильная
|
||||
./tools/build/build.sh
|
||||
|
||||
# закрепить конкретную версию
|
||||
HYSTERIA_VERSION_OVERRIDE=v2.12.2 ./tools/build/build.sh
|
||||
|
||||
# офлайн-сборка по закоммиченному tools/build/hysteria-lock.env
|
||||
HYSTERIA_CHANNEL=pinned ./tools/build/build.sh
|
||||
```
|
||||
|
||||
Фактически установленная версия видна в `/etc/hysteria/post-install.env` (`HY2_VERSION`), а способ её выбора — в `HY2_RESOLUTION`.
|
||||
|
||||
Обновление Hysteria на уже работающем сервере в текущем релизе не поддерживается: `reconfigure` намеренно не является Hysteria updater. Это сохраняет immutable‑контракт развёртывания.
|
||||
|
||||
## Обфускация
|
||||
|
||||
Новые установки HY2XS используют **Gecko**.
|
||||
|
||||
Gecko помечен upstream как **experimental**. Он достраивается поверх Salamander: помимо scramble он дополнительно фрагментирует QUIC handshake на пакеты случайного размера. HY2XS использует upstream‑defaults размеров пакетов `512/1200` как проверенный production‑профиль.
|
||||
|
||||
**Salamander остаётся поддержанным режимом совместимости.** Смена типа обфускации требует соответствующих изменений на клиенте: это изменение wire‑совместимости, а не косметическая настройка.
|
||||
|
||||
| | Gecko | Salamander |
|
||||
| --- | --- | --- |
|
||||
| Статус upstream | experimental | stable |
|
||||
| Роль в HY2XS | default для новых установок | режим совместимости |
|
||||
| Параметр | `HY2XS_HYSTERIA_OBFS_TYPE=gecko` | `HY2XS_HYSTERIA_OBFS_TYPE=salamander` |
|
||||
| В клиентской ссылке | `obfs=gecko` | `obfs=salamander` |
|
||||
|
||||
Экспериментальность upstream остаётся контролируемым риском, потому что одновременно выполняются три условия: Salamander доступен как fallback, каждая разрешённая версия проходит compatibility gate до выпуска пакета, и существующие серверы никогда не переводятся на Gecko молча.
|
||||
|
||||
Размеры пакетов Gecko не выносятся в конфигурацию: официальная схема `hysteria2://` не умеет их передавать, поэтому нестандартные значения сделали бы клиентскую ссылку неполной.
|
||||
|
||||
## Быстрый старт для нового сервера
|
||||
|
||||
Ниже приведён полный путь для оператора, который работает с Windows и ставит HY2XS на чистый Debian 13 сервер.
|
||||
@@ -379,10 +447,12 @@ HY2XS_IPV6_ENABLED=false
|
||||
HY2XS_TLS_MODE=acme
|
||||
HY2XS_ACME_TYPE=http
|
||||
HY2XS_HYSTERIA_AUTH_MODE=http
|
||||
HY2XS_HYSTERIA_OBFS_TYPE=salamander
|
||||
HY2XS_HYSTERIA_OBFS_TYPE=gecko
|
||||
HY2XS_UI_PUBLIC_ACCESS=false
|
||||
```
|
||||
|
||||
Если нужен режим совместимости со старыми клиентами, укажите `HY2XS_HYSTERIA_OBFS_TYPE=salamander`. Подробнее — в разделе [Обфускация](#обфускация).
|
||||
|
||||
### 9. Запустите установку
|
||||
|
||||
```bash
|
||||
@@ -396,7 +466,7 @@ HY2XS_UI_PUBLIC_ACCESS=false
|
||||
3. создаст runtime‑каталоги и service users;
|
||||
4. запишет `/etc/hy2xs/hy2xs.env`;
|
||||
5. разложит bundled HY2XS admin;
|
||||
6. скачает pinned Hysteria2 binary из upstream и проверит SHA256;
|
||||
6. скачает закреплённый в пакете Hysteria2 binary из upstream, проверит SHA256 и фактическую версию;
|
||||
7. создаст `/etc/hysteria/config.yaml`;
|
||||
8. установит systemd‑юниты;
|
||||
9. применит nftables‑правила;
|
||||
@@ -507,6 +577,7 @@ hy2xs-orchestrator status \
|
||||
|
||||
| Переменная | Назначение | Значение по умолчанию в packaged baseline |
|
||||
| --- | --- | --- |
|
||||
| `HY2XS_CONFIG_SCHEMA_VERSION` | Версия схемы конфигурации HY2XS. Конфигурация другой схемы отклоняется fail‑fast | `2` |
|
||||
| `HY2XS_IPV6_ENABLED` | IPv6‑режим. В production baseline должен быть `false` | `false` |
|
||||
| `HY2XS_DOMAIN` | Домен для ACME и deploy‑профиля | `fi.api.withen.pro` |
|
||||
| `HY2XS_DNS_AAAA_POLICY` | Поведение при наличии AAAA‑записи: `strict`, `warn`, `off` | `strict` |
|
||||
@@ -534,8 +605,8 @@ hy2xs-orchestrator status \
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API | `127.0.0.1` |
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT` | Порт trafficStats API | `36712` |
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` |
|
||||
| `HY2XS_HYSTERIA_OBFS_TYPE` | Obfuscation type. Фиксированное значение production‑профиля | `salamander` |
|
||||
| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Salamander password | `__GENERATE__` |
|
||||
| `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` |
|
||||
| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Пароль обфускации; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_HYSTERIA_BANDWIDTH_UP` | Hysteria2 upstream bandwidth | `50 mbps` |
|
||||
| `HY2XS_HYSTERIA_BANDWIDTH_DOWN` | Hysteria2 downstream bandwidth | `50 mbps` |
|
||||
| `HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH` | Игнорировать bandwidth клиента | `false` |
|
||||
@@ -717,7 +788,7 @@ git status --short
|
||||
Подготовьте build env:
|
||||
|
||||
```bash
|
||||
export PACKAGE_VERSION=0.2.2
|
||||
export PACKAGE_VERSION=1.0.0
|
||||
export BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ)
|
||||
|
||||
# Для переносимости между x86_64-серверами без AVX2 предпочтителен baseline artifact.
|
||||
@@ -728,6 +799,9 @@ export BUN_FLAVOR=x64-baseline
|
||||
export GO_ARCHIVE_SHA256=<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
|
||||
```
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -361,21 +361,69 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
v-if="dataForm.obfs.type === 'salamander'"
|
||||
:content="$t('hysteria.config.obfs.salamander.password')"
|
||||
placement="bottom"
|
||||
<template
|
||||
v-if="
|
||||
dataForm.obfs.type === 'salamander' && dataForm.obfs.salamander
|
||||
"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.salamander.password"
|
||||
prop="obfs.salamander.password"
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.salamander.password')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.obfs.salamander.password"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-form-item
|
||||
label="obfs.salamander.password"
|
||||
prop="obfs.salamander.password"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.obfs.salamander.password"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template
|
||||
v-if="dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko"
|
||||
>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.gecko.password')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.gecko.password"
|
||||
prop="obfs.gecko.password"
|
||||
>
|
||||
<el-input v-model="dataForm.obfs.gecko.password" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.gecko.minPacketSize')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.gecko.minPacketSize"
|
||||
prop="obfs.gecko.minPacketSize"
|
||||
>
|
||||
<el-input
|
||||
v-model.number="dataForm.obfs.gecko.minPacketSize"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.gecko.maxPacketSize')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.gecko.maxPacketSize"
|
||||
prop="obfs.gecko.maxPacketSize"
|
||||
>
|
||||
<el-input
|
||||
v-model.number="dataForm.obfs.gecko.maxPacketSize"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :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;
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,26 +4,115 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/model/bo"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func mustParse(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid url, got error: %v (raw=%s)", err, raw)
|
||||
}
|
||||
if parsed.Scheme != "hysteria2" {
|
||||
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_GeckoObfs(t *testing.T) {
|
||||
raw := buildHysteria2Url(
|
||||
"con-pass",
|
||||
"vpn.example.com",
|
||||
443,
|
||||
bo.ObfsShareConfig{Type: "gecko", Password: "gecko-secret"},
|
||||
"vpn.example.com",
|
||||
"",
|
||||
)
|
||||
|
||||
q := mustParse(t, raw).Query()
|
||||
if q.Get("obfs") != "gecko" {
|
||||
t.Fatalf("expected obfs=gecko, got %q", q.Get("obfs"))
|
||||
}
|
||||
if q.Get("obfs-password") != "gecko-secret" {
|
||||
t.Fatalf("expected gecko obfs password, got %q", q.Get("obfs-password"))
|
||||
}
|
||||
if q.Get("sni") != "vpn.example.com" {
|
||||
t.Fatalf("expected sni, got %q", q.Get("sni"))
|
||||
}
|
||||
if q.Get("insecure") != "0" {
|
||||
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_SalamanderObfs(t *testing.T) {
|
||||
raw := buildHysteria2Url(
|
||||
"con-pass",
|
||||
"vpn.example.com",
|
||||
443,
|
||||
bo.ObfsShareConfig{Type: "salamander", Password: "salamander-secret"},
|
||||
"vpn.example.com",
|
||||
"",
|
||||
)
|
||||
|
||||
q := mustParse(t, raw).Query()
|
||||
if q.Get("obfs") != "salamander" {
|
||||
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
|
||||
}
|
||||
if q.Get("obfs-password") != "salamander-secret" {
|
||||
t.Fatalf("expected salamander obfs password, got %q", q.Get("obfs-password"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_NoObfs(t *testing.T) {
|
||||
raw := buildHysteria2Url("pass", "example.com", 8443, bo.ObfsShareConfig{}, "", "")
|
||||
|
||||
parsed := mustParse(t, raw)
|
||||
if parsed.Host != "example.com:8443" {
|
||||
t.Fatalf("unexpected host: %s", parsed.Host)
|
||||
}
|
||||
q := parsed.Query()
|
||||
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
|
||||
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
|
||||
}
|
||||
if q.Get("insecure") != "0" {
|
||||
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_UnknownObfsTypeIsNotShared(t *testing.T) {
|
||||
// Неизвестный тип не должен попадать в ссылку: клиент получил бы
|
||||
// параметр, который не понимает.
|
||||
raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "future-obfs", Password: "x"}, "", "")
|
||||
|
||||
q := mustParse(t, raw).Query()
|
||||
if q.Get("obfs") != "" {
|
||||
t.Fatalf("unknown obfs type must not be shared, got %q", q.Get("obfs"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_ObfsWithoutPasswordIsNotShared(t *testing.T) {
|
||||
raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "gecko"}, "", "")
|
||||
|
||||
q := mustParse(t, raw).Query()
|
||||
if q.Get("obfs") != "" || q.Get("obfs-password") != "" {
|
||||
t.Fatalf("obfs without password must not be shared: %s", q.Encode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
|
||||
raw := buildHysteria2Url(
|
||||
"u@ser:#&=+ pass",
|
||||
"example.com",
|
||||
443,
|
||||
"salamander",
|
||||
"obf+s&pass=@x",
|
||||
bo.ObfsShareConfig{Type: "gecko", Password: "obf+s&pass=@x"},
|
||||
"exa mple.com",
|
||||
"my remark #1",
|
||||
)
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid url, got error: %v", err)
|
||||
}
|
||||
if parsed.Scheme != "hysteria2" {
|
||||
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
|
||||
}
|
||||
parsed := mustParse(t, raw)
|
||||
if parsed.User == nil {
|
||||
t.Fatal("expected userinfo to be present")
|
||||
}
|
||||
@@ -32,42 +121,200 @@ func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
|
||||
}
|
||||
|
||||
q := parsed.Query()
|
||||
if q.Get("obfs") != "salamander" {
|
||||
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
|
||||
}
|
||||
if q.Get("obfs-password") != "obf+s&pass=@x" {
|
||||
t.Fatalf("expected decoded obfs-password, got %q", q.Get("obfs-password"))
|
||||
}
|
||||
if q.Get("sni") != "exa mple.com" {
|
||||
t.Fatalf("expected decoded sni, got %q", q.Get("sni"))
|
||||
}
|
||||
if q.Get("insecure") != "0" {
|
||||
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
|
||||
}
|
||||
if parsed.Fragment != "my remark #1" {
|
||||
t.Fatalf("expected decoded fragment, got %q", parsed.Fragment)
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "u@ser:#&=+ pass") {
|
||||
t.Fatalf("raw uri must not contain unescaped userinfo: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_MinimalConfig(t *testing.T) {
|
||||
raw := buildHysteria2Url("pass", "example.com", 8443, "", "", "", "")
|
||||
// Регрессия на upstream-баг 2.9.3: `+` в credentials при разборе share link
|
||||
// превращался в пробел. Проверяем, что кодирование однозначно.
|
||||
func TestBuildHysteria2Url_PlusInCredentialsSurvivesRoundTrip(t *testing.T) {
|
||||
cases := []string{"a+b", "a b", "a#b", "a@b", "a/b", "a?b", "a&b", "a=b", "a%b", "тест"}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid url, got error: %v", err)
|
||||
for _, value := range cases {
|
||||
raw := buildHysteria2Url(value, "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: value}, "", "")
|
||||
parsed := mustParse(t, raw)
|
||||
|
||||
if got := parsed.User.Username(); got != value {
|
||||
t.Fatalf("userinfo round-trip failed for %q: got %q (raw=%s)", value, got, raw)
|
||||
}
|
||||
if got := parsed.Query().Get("obfs-password"); got != value {
|
||||
t.Fatalf("obfs-password round-trip failed for %q: got %q (raw=%s)", value, got, raw)
|
||||
}
|
||||
}
|
||||
if parsed.Host != "example.com:8443" {
|
||||
t.Fatalf("unexpected host: %s", parsed.Host)
|
||||
|
||||
// Ключевой инвариант: литеральный `+` кодируется как %2B и не может быть
|
||||
// прочитан клиентом как пробел, а пробел кодируется отдельно от него.
|
||||
plus := buildHysteria2Url("a+b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a+b"}, "", "")
|
||||
if !strings.Contains(mustParse(t, plus).RawQuery, "%2B") {
|
||||
t.Fatalf("literal '+' must be percent-encoded as %%2B: %s", plus)
|
||||
}
|
||||
q := parsed.Query()
|
||||
if q.Get("insecure") != "0" {
|
||||
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
|
||||
}
|
||||
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
|
||||
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
|
||||
|
||||
space := buildHysteria2Url("a b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a b"}, "", "")
|
||||
if mustParse(t, space).Query().Get("obfs-password") == "a+b" {
|
||||
t.Fatalf("space and '+' must not collapse to the same value: %s", space)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveShareSni(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
acmeDomain string
|
||||
publicHost string
|
||||
envDomain string
|
||||
want string
|
||||
}{
|
||||
{name: "acme domain wins", acmeDomain: "acme.example.com", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "acme.example.com"},
|
||||
{name: "file tls falls back to product domain", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "env.example.com"},
|
||||
{name: "public host used when domain is empty", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "", want: "vpn.example.com"},
|
||||
{name: "ipv4 public host is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "", want: ""},
|
||||
{name: "ipv4 env domain is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "198.51.100.7", want: ""},
|
||||
{name: "whitespace is trimmed", acmeDomain: " acme.example.com ", publicHost: "", envDomain: "", want: "acme.example.com"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("HY2XS_DOMAIN", tc.envDomain)
|
||||
if got := resolveShareSni(tc.acmeDomain, tc.publicHost); got != tc.want {
|
||||
t.Fatalf("resolveShareSni(%q, %q) with HY2XS_DOMAIN=%q = %q, want %q",
|
||||
tc.acmeDomain, tc.publicHost, tc.envDomain, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObfsShare_FromServerConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlConfig string
|
||||
wantType string
|
||||
wantPassword string
|
||||
}{
|
||||
{
|
||||
name: "gecko",
|
||||
yamlConfig: "obfs:\n type: gecko\n gecko:\n password: gecko-pass\n minPacketSize: 512\n maxPacketSize: 1200\n",
|
||||
wantType: "gecko",
|
||||
wantPassword: "gecko-pass",
|
||||
},
|
||||
{
|
||||
name: "salamander",
|
||||
yamlConfig: "obfs:\n type: salamander\n salamander:\n password: salamander-pass\n",
|
||||
wantType: "salamander",
|
||||
wantPassword: "salamander-pass",
|
||||
},
|
||||
{
|
||||
name: "no obfs section",
|
||||
yamlConfig: "listen: 0.0.0.0:443\n",
|
||||
},
|
||||
{
|
||||
name: "type without matching subsection",
|
||||
yamlConfig: "obfs:\n type: gecko\n salamander:\n password: mismatched\n",
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
yamlConfig: "obfs:\n type: future\n gecko:\n password: p\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var config bo.Hysteria2ServerConfig
|
||||
if err := yaml.Unmarshal([]byte(tc.yamlConfig), &config); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
got := config.ObfsShare()
|
||||
if got.Type != tc.wantType || got.Password != tc.wantPassword {
|
||||
t.Fatalf("ObfsShare() = %+v, want type=%q password=%q", got, tc.wantType, tc.wantPassword)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHysteria2ServerConfig_ParsesModernUpstreamSchema(t *testing.T) {
|
||||
raw := `listen: 0.0.0.0:443
|
||||
tls:
|
||||
cert: /etc/hysteria/server.crt
|
||||
key: /etc/hysteria/server.key
|
||||
clientCA: /etc/hysteria/client-ca.crt
|
||||
ech:
|
||||
keyPath: /etc/hysteria/ech.pem
|
||||
obfs:
|
||||
type: gecko
|
||||
gecko:
|
||||
password: p
|
||||
minPacketSize: 512
|
||||
maxPacketSize: 1200
|
||||
bandwidth:
|
||||
up: 50 mbps
|
||||
down: 50 mbps
|
||||
disableLossCompensation: false
|
||||
congestion:
|
||||
type: bbr
|
||||
bbrProfile: standard
|
||||
quic:
|
||||
disableStatelessReset: false
|
||||
mimic:
|
||||
enabled: false
|
||||
interface: eth0
|
||||
xdpMode: skb
|
||||
realm:
|
||||
stunServers:
|
||||
- stun.example.com:3478
|
||||
ipMode: dual
|
||||
portMapping:
|
||||
enabled: false
|
||||
timeout: 10s
|
||||
masquerade:
|
||||
type: proxy
|
||||
proxy:
|
||||
url: https://example.com
|
||||
rewriteHost: true
|
||||
insecure: false
|
||||
xForwarded: true
|
||||
trafficStats:
|
||||
listen: 127.0.0.1:36712
|
||||
`
|
||||
|
||||
var config bo.Hysteria2ServerConfig
|
||||
if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if config.ECH == nil || config.ECH.KeyPath == nil || *config.ECH.KeyPath != "/etc/hysteria/ech.pem" {
|
||||
t.Fatal("ech.keyPath was not parsed")
|
||||
}
|
||||
if config.TLS == nil || config.TLS.ClientCA == nil || *config.TLS.ClientCA != "/etc/hysteria/client-ca.crt" {
|
||||
t.Fatal("tls.clientCA was not parsed")
|
||||
}
|
||||
if config.Congestion == nil || config.Congestion.Type == nil || *config.Congestion.Type != "bbr" {
|
||||
t.Fatal("congestion.type was not parsed")
|
||||
}
|
||||
if config.Congestion.BBRProfile == nil || *config.Congestion.BBRProfile != "standard" {
|
||||
t.Fatal("congestion.bbrProfile was not parsed")
|
||||
}
|
||||
if config.Bandwidth == nil || config.Bandwidth.DisableLossCompensation == nil || *config.Bandwidth.DisableLossCompensation {
|
||||
t.Fatal("bandwidth.disableLossCompensation was not parsed")
|
||||
}
|
||||
if config.QUIC == nil || config.QUIC.DisableStatelessReset == nil || *config.QUIC.DisableStatelessReset {
|
||||
t.Fatal("quic.disableStatelessReset was not parsed")
|
||||
}
|
||||
if config.Mimic == nil || config.Mimic.Enabled == nil || *config.Mimic.Enabled {
|
||||
t.Fatal("mimic section was not parsed")
|
||||
}
|
||||
if config.Realm == nil || len(config.Realm.StunServers) != 1 || config.Realm.PortMapping == nil {
|
||||
t.Fatal("realm section was not parsed")
|
||||
}
|
||||
if config.Masquerade == nil || config.Masquerade.Proxy == nil ||
|
||||
config.Masquerade.Proxy.XForwarded == nil || !*config.Masquerade.Proxy.XForwarded {
|
||||
t.Fatal("masquerade.proxy.xForwarded was not parsed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую
|
||||
|
||||
@@ -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-формулировка
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 и подготавливает рабочее серверное окружение.**
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Установленный бинарник обязан совпадать с версией, замороженной в metadata
|
||||
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
|
||||
* расхождение там — предупреждение, а не отказ.
|
||||
*/
|
||||
async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise<void> {
|
||||
const packagedVersion = context.hysteriaVersion.trim();
|
||||
if (!packagedVersion || packagedVersion === "unknown") {
|
||||
return;
|
||||
}
|
||||
if (context.config.tlsMode === "file") {
|
||||
await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`;
|
||||
await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`;
|
||||
await runVisible`grep -q 'insecure: false' /etc/hysteria/config.yaml`;
|
||||
}
|
||||
if (context.config.tlsMode === "self_signed_dev") {
|
||||
await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`;
|
||||
await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`;
|
||||
await runVisible`grep -q 'insecure: true' /etc/hysteria/config.yaml`;
|
||||
|
||||
const raw = await runSecret`/usr/local/bin/hysteria version`;
|
||||
const match = raw.match(/v\d+\.\d+\.\d+/);
|
||||
const effective = match ? match[0] : raw.trim();
|
||||
|
||||
if (effective !== packagedVersion) {
|
||||
const message = `effective Hysteria version ${effective} does not match package metadata ${packagedVersion}`;
|
||||
if (context.mode === "install") {
|
||||
throw new Error(message);
|
||||
}
|
||||
info(`warning: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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 -");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Executable
+527
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user