Files
HY2XS_flamy/docs/11-testing-and-acceptance.md
T
founder 672d455467 fix: закрыть каналы утечки секретов и сделать PHASE 1 владением оркестратора
Hardening-проход перед первой сборкой на Debian. Три из найденного не
воспроизводились ни на одном dry-run и проявились бы только на живом сервере.

Установка

* preflight внутри install вызывался дважды и оба раза проверял clean-host.
  Ко второму вызову на диске лежал собственный /var/lib/hy2xs/install-state.json,
  записанный после первого preflight, и опознавался как маркер посторонней
  установки: КАЖДАЯ чистая установка падала сразу после apt-get с
  fatal_post_apply и оставляла сервер наполовину настроенным. Чистота хоста —
  условие входа в операцию, возможности платформы проверяются уже внутри
  PHASE 1, поэтому checkCleanHost стал отдельным параметром без умолчания.

* PHASE 1 начиналась в install.sh: shell сам создавал /usr/local/lib/hy2xs,
  ставил бинарник, вешал symlink и копировал runtime-пакет, и только потом
  запускал оркестратор с его собственным preflight. Отказ того preflight
  объявлялся fatal_pre_apply — «на сервере ничего не изменено» — при уже
  созданном каталоге оркестратора. Отследить владение мутацией невозможно,
  пока мутируют двое: install.sh больше не изменяет ничего, раскладку
  выполняет steps/bootstrap.ts под ownership.bootstrapTouched, пути попали
  в owned_paths. Как следствие удалено деление clean-host на фазы.

* diagnosticsCollect стояла перед rollback обычным await в install и в
  reconfigure. На заполненном диске она падает сама и отменяла откат целиком.
  Диагностика — best effort, откат — обязателен.

* reconfigure/repair выбирали записываемую фазу отказа регулярным выражением
  по тексту ошибки. Переведено на ownership-флаги.

Секреты

* Журнал админки писал RequestURI, то есть путь вместе с query. Hysteria
  обращается к /internal/hysteria/auth?access_token=<секрет> при каждом
  подключении пира, поэтому действующий machine token оседал открытым текстом
  в hy2xs-admin.log, который отдаётся через ExportLog и попадает в
  diagnostics-бандл. Логируется путь; значения query не пишутся, имена —
  пишутся. Канала было два: gin.Default() печатает path?query в stdout,
  оттуда в journald и в тот же бандл, — панель переведена на gin.New() +
  Recovery(). Журналы внутри бандла и журнал Hysteria из ExportLog теперь
  проходят санитайз. Сравнение токена — constant time.

* Config API позволял прочитать и подменить ключи приложения: getConfig и
  listConfig принимали произвольный ключ, а проверка записи была denylist'ом
  из трёх ключей оркестратора. Запрос ?key=PEER_SECRET_ENCRYPTION_KEY отдавал
  master-key шифрования секретов пиров. Доступ переведён на allowlist, маршрут
  getConfig удалён целиком — потребителей у него не было ни одного.

Пиры

* Импорт применялся по одной записи вне транзакции, вопреки собственному
  контракту. Валидация не знает, что уже лежит в базе: cross-conflict по
  UNIQUE(name) оставлял часть файла применённой. Применение выполняется одной
  транзакцией, криптоматериал считается до её открытия.

* Файл импорта мог содержать хвостовой JSON-документ, который молча не
  применялся. После разбора проверяется io.EOF.

* Экспорт разделён на «Экспорт настроек» и «Резервная копия» с секретами и
  подтверждением: обычный экспорт выдаёт пирам новые секреты при импорте, и
  прежние клиентские ссылки после переноса переставали работать.

Сборка

* Два stale-грепа в приёмке роняли build.sh в самом конце, внутри
  verify_archive. Первый искал в smoke.ts исчезнувший литерал URL, второй
  совпадал с router_test.go, который перечисляет удалённые маршруты, потому
  что проверяет их отсутствие: добавление регрессионного теста ломало сборку.

* verify_archive требовал наличия мутирующей строки в install.sh. Инвариант
  перевёрнут: их не должно быть ни одной.

Очистка

* Удалены entity.LegacyAccount, миграции 002/003 и мёртвые хелперы
  listSQLMigrationFiles и envInt: v1 не мигрирует базу 0.x ни при каком
  сценарии. Номера оставшихся миграций сохранены. H UI-словарь убран из
  обычных доков, в docs/14 он остаётся — там это имена объектов для удаления.

* Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry:
  203.0.113.5 из RFC-примеров считался публичным адресом сервера. Отказ
  резолвера отделён от отсутствия A-записи.

Проверено: bun test 233, go test 71, tsc/vue-tsc, bash -n 11 скриптов,
приёмка прогнана против дерева.
2026-08-28 05:27:10 +05:00

699 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Testing and acceptance
## Цель документа
Зафиксировать 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; нужен Go)
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
### Проверяем
1. builder запускается на Debian 13 amd64 build host
2. итоговый пакет собирается без target-side шагов
3. bundled HY2XS admin реально входит в пакет
4. package metadata / build id присутствуют
5. compiled Bun/TypeScript orchestrator artifact присутствует
6. в пакет не попадает build-мусор
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` | отклонено с указанием на чистую установку |
| `HY2XS_CONFIG_SCHEMA_VERSION` отсутствует | отклонено как legacy-конфигурация |
| `HY2XS_CONFIG_SCHEMA_VERSION=` (пусто) | отклонено как legacy-конфигурация |
| `HY2XS_CONFIG_SCHEMA_VERSION=2` | принято |
Отсутствие маркера схемы отклоняется намеренно: до v1 этого поля не
существовало, поэтому именно пустое значение — самый вероятный признак
конфигурации 0.x. Любой fallback здесь молча превращал бы legacy-конфиг в
якобы валидный.
Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации».
Рендер конфига (`orchestrator/test/render-config.test.ts`):
- Gecko рендерит **только** gecko-подблок;
- Salamander рендерит **только** salamander-подблок;
- в конфиге никогда нет двух подтипов obfs одновременно;
- шаблон не содержит захардкоженного типа обфускации;
- пароль с пробелами и спецсимволами экранируется;
- YAML-инъекция через пароль отклоняется даже в обход env-валидации.
## A5. Граница установки и поколение (unit)
`orchestrator/test/clean-host.test.ts`:
- чистый хост проходит;
- **каждый** маркер по отдельности останавливает установку;
- список покрывает состояние, юниты, бинарник Hysteria и наследие 0.x;
- пути из конфигурации (`HY2XS_INSTALL_DIR`, `HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`)
попадают в список, а не только значения по умолчанию;
- всё, что удаляет `purge-v0.sh`, покрыто маркерами clean-host: два списка
описывают одну границу и не имеют права разъезжаться;
- bootstrap-пути (`/usr/local/lib/hy2xs`, `/usr/local/lib/hy2xs/package`,
`/usr/local/bin/hy2xs-orchestrator`) остаются маркерами **без исключений**:
их создаёт оркестратор уже после проверки чистоты хоста, поэтому «мягкой»
версии списка для PHASE 1 больше не существует;
- эти пути берутся из `config/profile.ts`, а не из копий строк: шаг, который
их создаёт, и контракт, который на них отказывает, обязаны читать одно
значение;
- сообщение перечисляет найденные маркеры и говорит, что хост не изменён.
`orchestrator/test/install-boundary.test.ts`:
- под read-only guard недоступны `writeText`, `writeTextAtomic` и все
`runMutating*`-раннеры;
- read-only раннеры под guard'ом продолжают работать: разделение API — это не
запрет наблюдения, а запрет мутации;
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
ошибки;
- `fatal_pre_apply` недостижим ни при одном взведённом флаге, включая
`stateWritten`: записанный `install-state.json` уже делает хост изменённым;
- начатая (не обязательно завершённая) установка пакетов уже даёт
`fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал,
установщик заявил, что ничего не тронул»;
- начатый bootstrap (`bootstrapTouched`) тоже даёт `fatal_post_apply`: раскладку
выполняет оркестратор, и она учитывается наравне с остальными шагами.
`orchestrator/test/install-sequence.test.ts` — порядок фаз, который иначе
проверяется только на живом сервере:
- `preflight()` в режиме install **отказывается работать без явного
`checkCleanHost`**, и отказ наступает до любой работы с системой;
- clean-host запрашивается ровно один раз за операцию и **до** первой записи
install-state — регрессия на сценарий, где повторный preflight после
`installDeps` опознавал собственный `install-state.json` как маркер чужой
установки и валил каждую чистую установку;
- проход capabilities явно отказывается от clean-host;
- `bootstrapTouched` взводится **перед** `bootstrapRuntime`, а сам bootstrap
идёт до `installDeps`;
- дальнейшая установка работает от установленного runtime-пакета;
- `diagnosticsCollect` обёрнута в `try/catch`, и `catch` стоит **до** отката:
диагностика — best effort, откат — обязателен;
- в `package/install.sh` не осталось ни одной мутирующей команды, и он
передаёт управление оркестратору через `exec`;
- классификация отказа `reconfigure`/`repair` идёт по ownership-флагам, а не по
регулярному выражению над текстом ошибки.
`orchestrator/test/install-state.test.ts`:
- маркер текущего поколения принимается;
- маркер без полей поколения отклоняется, **несмотря на `installed: true`**;
- чужой `product`, `release_line` или `config_schema_version` отклоняются;
- записываемый маркер всегда несёт идентификацию поколения;
- незавершённая установка подсказывает `repair --allow-partial-state`.
## A6. Редактирование секретов (unit)
`orchestrator/test/redaction.test.ts`:
- machine token не переживает редакцию серверного конфига — регрессия на
построчное правило `auth:`, оставлявшее нетронутым `auth.http.url`;
- obfs-пароль не переживает редакцию;
- результат остаётся валидным YAML;
- несекретные поля сохраняются: диагностика должна оставаться полезной;
- неизвестное поле с секретоподобным именем вырезается;
- `acme.dns.config` вырезается целиком;
- невалидный YAML не роняет редакцию и всё равно чистится;
- секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное
(`HY2_AUTH_URL`);
- URL под **произвольным** именем ключа теряет встроенные учётные данные и
секретные query-параметры, но сохраняет адрес; то же для URL внутри списка;
- `redactLogText` вырезает machine token из строки journald, сохраняя host,
port и path; ловит секрет и вне URL; не трогает обычные строки; сохраняет
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
редактировались env и YAML, а `journal-admin.log` копировался как есть.
## A7. Machine token в журналах (unit)
`apps/middleware/log_test.go` — запрос
`/internal/hysteria/auth?access_token=SUPER_SECRET_SENTINEL`:
- sentinel **не появляется** в журнале ни в каком виде;
- в журнале есть `reqPath`, поля `reqUri` нет;
- имя query-параметра сохраняется (`reqQueryKeys`), значение — нет;
- пустой список параметров в журнал не пишется;
- то же правило действует на операторских маршрутах, а не только на машинном.
`apps/service/log_sanitize_test.go` — тот же санитайз на стороне админки: журнал
Hysteria покидает сервер через `ExportLog`, а `HY2_AUTH_URL` несёт
`access_token`, который upstream волен упомянуть в сообщении об ошибке.
## A8. Инвариант публичного endpoint (unit)
`orchestrator/test/network-endpoint.test.ts` — проба подменяет и DNS, и список
локальных адресов, поэтому тест не зависит ни от сети, ни от интерфейсов машины
разработчика.
| Сценарий | Результат |
| --- | --- |
| A-запись == текущий публичный IPv4 | PASS |
| A-запись == старый IPv4 | FAIL, в тексте оба адреса |
| A-запись отсутствует | FAIL |
| A == текущий + чужой | FAIL |
| у сервера 2 публичных IP, DNS использует один | PASS |
| `PUBLIC_HOST` — правильный IPv4-литерал | PASS |
| `PUBLIC_HOST` — устаревший IPv4-литерал | FAIL |
| `DOMAIN` совпадает, отдельный `PUBLIC_HOST` устарел | FAIL |
| `PUBLIC_HOST` совпадает, отдельный TLS-домен устарел | FAIL |
| нет ни одного локального публичного IPv4 | FAIL |
| `HY2XS_PUBLIC_ENDPOINT_POLICY` = strict / warn / off | fail / warn / skip |
| отсутствие A-записи при любой политике | FAIL |
| отказ резолвера (SERVFAIL/таймаут/отказ) при любой политике | FAIL, отдельный текст |
Отдельно проверяется классификация IPv4. Список исключений приведён к IANA
Special-Purpose Address Registry: приватные, CGNAT, link-local, multicast,
reserved, benchmarking (`198.18/15`), 6to4-anycast и **документационные**
диапазоны (`192.0.2/24`, `198.51.100/24`, `203.0.113/24`) не считаются
публичным адресом сервера. Регрессия: `203.0.113.5` из RFC-примеров раньше
проходил проверку как обычный публичный адрес. Границы проверяются с обеих
сторон — `172.32.0.0`, `192.0.1.1`, `198.20.0.1` и `203.0.112.255` считаются
публичными.
Отказ резолвера отделён от отсутствия записи: `ENODATA`/`ENOTFOUND`/`NXDOMAIN`
— это «нет A-записи» и чинится в DNS-панели, всё остальное — «резолвер не
ответил» и чинится в `/etc/resolv.conf`. Раньше оба случая печатались как
«has no A-record», и при сломанном резолвере оператор шёл править запись,
которая была на месте. Фатальны оба: без ответа резолвера проверка не выполнена,
а не «выполнена с замечанием».
## A9. Регистрация маршрутов (unit)
`apps/router/router_test.go` — единственное место, где ошибка проявляется
**паникой при старте сервиса**, а не ответом с кодом. Конфликт с
wildcard-маршрутом фронтенда или дублирующая регистрация обнаружились бы иначе
только на живом сервере.
- контур маршрутов собирается без паники;
- machine-auth зарегистрирован ровно на `constant.HysteriaMachineAuthPath`;
- операторский и auth API — под `constant.AdminAPIBase`;
- ни один маршрут не начинается со старого пространства имён;
- удалённые маршруты (включая `exportConfig`/`importConfig` и `getConfig`) не
вернулись;
- пространство `/api/config` закрыто: в нём ровно четыре маршрута, и любой
новый обязан быть добавлен в тест осознанно;
- `/healthz` на месте.
## A9a. Доступ к таблице `config` (unit)
`apps/model/constant/config_test.go` — allowlist как структура, а не как
соглашение:
- ни один внутренний ключ не читается и не записывается через API;
- `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` и
`HYSTERIA2_TRAFFIC_STATS_SECRET` поимённо объявлены внутренними;
- пользовательские настройки остаются доступными;
- множество записываемых ключей — подмножество читаемых;
- неизвестный ключ закрыт **по умолчанию**: забытый при denylist ключ был бы
сразу публичным.
`apps/controller/config_test.go` — то же на уровне HTTP:
- чтение и запись каждого секрета отклоняются;
- секрет, спрятанный среди разрешённых ключей, отклоняет весь запрос;
- отказ наступает **до** обращения к базе (тест работает без SQLite — сам факт,
что обработчик не падает, это и доказывает);
- ключи оркестратора отклоняются с указанием владельца, а не общим «нет такого
ключа»: оператор должен быть отправлен к `hy2xs-orchestrator reconfigure`.
## A10. Импорт пиров (unit)
`apps/service/peer_import_test.go`:
- выгрузка, сделанная `ExportPeer`, принимается без правок;
- имя проверяется теми же правилами, что и при обычном создании пира: длина,
набор символов, отсутствие пробелов и переводов строки;
- `bootstrap-admin-peer` не может быть импортирован ни по имени, ни по `authId`:
его секрет продублирован в `/etc/hy2xs/bootstrap-admin.secret`;
- диапазоны `quotaBytes`, `expiresAt`, `maxDevices`, `disabled`, `bannedUntil`,
счётчиков трафика и длины секрета проверяются;
- sentinel-значения (`quotaBytes = -1`, `maxDevices = 0`) остаются валидными;
- дубликаты имени и `authId` внутри одной партии отклоняются;
- партия сверх лимита отклоняется;
- невалидная **последняя** запись отклоняет весь файл: импорт применяется
целиком или не применяется вовсе.
`apps/service/peer_import_tx_test.go` — та же гарантия уже на уровне базы, на
настоящей SQLite. Валидация не даёт применить испорченный файл, но она ничего
не говорит о конфликте с тем, что УЖЕ лежит в базе:
- валидная партия применяется целиком;
- **cross-conflict откатывается полностью**: пусть в базе есть
`A(auth_id=aaa, name=alice1)` и `B(auth_id=bbb, name=bob123)`, а файл несёт
`(auth_id=aaa, name=bob123)` — поиск найдёт A по `auth_id` и попытается
переименовать её в `bob123`, прямо в `UNIQUE(name)`. Записи, шедшие в файле
до конфликтной, не должны остаться применёнными. Тест дополнительно
убеждается, что отказ пришёл **из базы**, а не из валидации;
- дубликат `auth_id` на вставке ведёт себя так же;
- отказ валидации не доходит до базы вовсе;
- пир установщика защищён и внутри транзакции;
- обновление без секрета в файле не перезаписывает существующий секрет.
`apps/controller/peer_test.go` — разбор загруженного файла:
- файл с **хвостовым** JSON-документом отклоняется: `json.Decoder` читает первый
документ и останавливается, поэтому раньше оператор видел «импорт выполнен»,
а вторая половина файла молча не применялась;
- неизвестные поля и файл не с расширением `.json` отклоняются;
- корректный одиночный документ доходит до базы и создаёт пира.
## A7. Контракт версий (build)
Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до
создания tarball при рассинхроне `versions.env` с `PACKAGE_VERSION`,
`packageManager` обоих `package.json`, схемой в `package/config/hy2xs.env`,
константами, скомпилированными в оркестратор, директивой `go` в `apps/go.mod`,
metadata пакета и версией, которую сообщает собранный `hy2xs-admin`.
Разбор upstream `hashes.txt` покрыт `orchestrator/test/hysteria-release.test.ts`:
реальный формат релиза, отсутствие путаницы `hysteria-linux-amd64` с
`hysteria-linux-amd64-avx`, форма `sha256:<hex>`, верхний регистр,
противоречивые записи, отсутствие нужной строки.
## B. Target install tests
### На чистом Debian 13 проверяем
1. пакет запускается без ручной сборки на сервере
2. Hysteria2 скачивается с official upstream
3. bundled HY2XS admin раскладывается локально из пакета
4. создаются нужные каталоги
5. создаются systemd unit-файлы
6. создаются `hy2xs.env` и `post-install.env` с правами `0600 root:root`
7. baseline firewall применяется корректно через staged mode
8. SSH остаётся доступным
9. `reconfigure --dry-run` выводит план изменений
10. `reconfigure --apply` применяет изменения и проходит smoke
## C. Runtime tests
1. `hysteria-server` active
2. `hy2xs-admin` active
3. Hysteria слушает только IPv4 (`0.0.0.0:<udp_port>`)
4. HY2XS admin слушает ожидаемый `HY2XS_UI_BIND_HOST:<ui_port>`
5. тестовый совместимый клиент подключается
6. идёт реальный трафик
7. лимит 50/50 Mbps соблюдается при согласованной клиентской конфигурации
8. reboot не ломает baseline
9. Hysteria2 управляется systemd unit, а не внутренним updater'ом admin panel
10. нет IPv6 listen (`[::]`) для Hysteria/HY2XS admin
11. `trafficStats.secret` не равен `JWT_SECRET`
12. bootstrap admin secret существует и имеет `0600`
13. `trafficStats` API: корректный secret принимает запрос, неверный secret отклоняется
14. TLS mode в `config.yaml` соответствует runtime env (`acme|file|self_signed_dev`)
15. при `HY2XS_TLS_MODE=acme` в `config.yaml` выставлен `acme.type` из `HY2XS_ACME_TYPE`
16. direct `hysteria2://` node URL в API/QR формируется по `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`; subscription delivery endpoint отключён в baseline и не входит в acceptance
17. `nft -c -f /etc/nftables.conf` проходит после apply
18. пароль admin и `con_pass` не перезаписываются при рестарте `hy2xs-admin`
19. остановка/рестарт UI не останавливает `hysteria-server`
20. traffic accounting/kick ориентируются на systemd status, а не на SQLite `HYSTERIA2_ENABLE`
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
окна, maxIncomingStreams, disablePathMTUDiscovery == baseline
maxIdleTimeout == 30s
trafficStats:
listen == runtime env
secret непустой
auth:
type == http
url == http://127.0.0.1:<UI_PORT>/internal/hysteria/auth?access_token=<machine token>
insecure == (tlsMode == self_signed_dev)
TLS:
acme-режим не содержит секции tls
acme: type/email/ca/dir/listenHost/первый домен == профиль
file-режим не содержит секции acme
верхний уровень:
нет секций вне production-профиля
```
`maxIdleTimeout` присутствовал в профиле, но не проверялся: конфиг с уехавшим
idle timeout проходил семантическую проверку. Точно так же `auth.http.url`
раньше сверялся только на наличие подстроки `access_token=`, из-за чего
уехавший порт или путь остались бы незамеченными — а это единственный канал
допуска пиров.
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
уходит в логи и в diagnostics-бандл. Это закреплено отдельным тестом.
## 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. клиент подключается **именно по ссылке, которую выдаёт production-код**;
7. TCP forwarding;
8. UDP forwarding;
9. `trafficStats` с валидным secret;
10. `trafficStats` с невалидным secret отклоняется;
11. per-peer accounting содержит аутентифицированного пира;
12. перезапуск сервера;
13. быстрое переподключение клиента (поведение stateless reset).
Пункт 6 — тот самый, который ловит класс ошибок, неизбежный при наивном включении Gecko: сервер работает, ссылка формально валидна, а клиент по ней не подключается.
### Одна реализация URI, а не две
Ссылка берётся из production-генератора через `apps/tools/share-uri`, который
вызывает ту же `service.BuildHysteria2ShareURI`, что и панель.
Раньше внутри e2e жила **вторая** реализация URI на bash. Go-юнит-тесты
проверяли production-генератор, e2e проверял свою функцию — и дрейф любой из
них оставлял обе группы тестов зелёными.
Единственное расхождение с пользовательской ссылкой — `insecure=1`: e2e
работает на самоподписанном сертификате. Это расхождение ограничено с двух
сторон:
- e2e отдельно печатает и проверяет **production-вариант** ссылки
(`insecure=0`, корректные `obfs` и `sni`);
- Go-тест `TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam`
доказывает, что кроме этого параметра ссылки совпадают побайтово;
- Go-тест `TestBuildHysteria2Url_ProductionPathNeverDisablesVerification`
фиксирует, что production-путь никогда не передаёт `insecure=1`.
Для запуска e2e нужен Go (`GO_BIN`).
## 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;
- вырезается **неизвестное** поле с секретным именем;
- URL под произвольным именем ключа (`endpoint:`) теряет учётные данные и
`access_token`, но сохраняет адрес; то же для URL внутри списка;
- не-URL скаляры (`50 mbps`, `0.0.0.0:443`, `10.0.0.1:1080`, `30s`, числа)
проходят санитайзер без изменений;
- пути к файлам (`tls.key`, `ech.keyPath`, `clientCA`) остаются видимыми.
Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами:
граница определяется значением, а не именем ключа.
## D0. Граница установки на живом сервере
Проверяется на хосте, где уже стоит предыдущая установка:
1. `install.sh` завершается отказом на PHASE 0;
2. `/usr/local/lib/hy2xs` **не создан и не изменён**;
3. `/var/lib/hy2xs/install-state.json` не перезаписан;
4. `hysteria-server` и `hy2xs-admin` остались `active`;
5. в тексте отказа перечислены найденные маркеры и указан
`docs/14-legacy-cleanup.md`;
6. после `tools/legacy/purge-v0.sh --apply --yes-i-know` установка проходит.
Пункты 2–4 — прямая регрессия: прежний установщик успевал переписать
`/usr/local/lib/hy2xs` и `install-state.json`, а затем откатом останавливал и
выключал работающие службы старой установки.
## D1. Отказ сразу после успешной PHASE 0 (fault injection)
Проверяется на чистом хосте. Это узкая щель между «PHASE 0 прошла» и «первая
мутирующая операция упала» — место, где установщик раньше врал.
1. PHASE 0 проходит успешно;
2. `installDeps` ломается искусственно (например, недоступный apt-репозиторий
или временно испорченный `/etc/apt/sources.list.d/`);
3. установка завершается отказом;
4. в выводе **нет** `fatal_pre_apply` и нет фразы про «ничего не применялось»;
5. `/var/lib/hy2xs/install-state.json` существует и честно показывает
`phase: failed` с текстом ошибки;
6. `owned_paths` в маркере содержит `/usr/local/lib/hy2xs`,
`/usr/local/bin/hy2xs-orchestrator` и `/usr/local/lib/hy2xs/package`
всё, что операция действительно создала;
7. diagnostics-бандл собран;
8. `hy2xs-orchestrator status` не заявляет установку успешной.
До исправления шаги 4–7 давали противоположный результат: `install-state.json`
уже лежал на диске, но отказ классифицировался как pre-apply, обработка
состояния пропускалась, а следующая установка на этой машине отказывалась по
clean-host контракту из-за оставшегося маркера.
Пункт 6 закрывает вторую половину той же щели. Пока раскладку оркестратора и
runtime-пакета выполнял `install.sh`, эти пути не принадлежали никому: они не
попадали в `owned_paths`, а отказ **второго** preflight (сменился DNS, занялся
порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не
изменено» — при уже созданном каталоге оркестратора.
## D1a. Проход установки не спотыкается о собственный маркер
Проверяется на чистом хосте, обычной успешной установкой.
1. `install.sh` доходит до `preflight capabilities` **после** `apt-get`;
2. установка на этом шаге **не** падает с текстом «обнаружена предыдущая или
посторонняя установка»;
3. установка доходит до `installed`.
Это сценарий, который не воспроизводится ни на одном dry-run: clean-host внутри
`install` проверялся дважды, и ко второму разу на диске уже лежал собственный
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight. Каждая
чистая установка падала сразу после `apt-get`, получала `fatal_post_apply` и
оставляла сервер наполовину настроенным. Структурно закреплено в
`orchestrator/test/install-sequence.test.ts`.
## D2. Устаревший DNS после смены IPv4 провайдером
Проверяется на рабочей установке.
```text
сервер: текущий публичный IPv4 = B
DNS: A-запись = A (старый адрес)
hy2xs-orchestrator doctor
→ FAIL
→ в выводе присутствуют и A, и B
обновить A-запись на B, дождаться TTL
hy2xs-orchestrator doctor
→ PASS
```
Дополнительно: `reconfigure --apply` при устаревшей A-записи тоже обязан
отказать — инвариант живёт в общем `preflight`, а не в одном `doctor`.
## D. Negative tests
1. не Debian 13
2. порт уже занят
3. старое конфликтующее состояние уже существует
4. домен / SNI заданы некорректно
5. bundled UI отсутствует в пакете
6. Hysteria upstream недоступен
7. firewall применился частично
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 — падает сборка, не установка
14. `HY2XS_PUBLIC_HOST` резолвится не на этот сервер
15. `HY2XS_DOMAIN` резолвится не на этот сервер при отличном от него `PUBLIC_HOST`
16. A-запись содержит правильный адрес и чужой одновременно
17. неизвестное значение `HY2XS_PUBLIC_ENDPOINT_POLICY`
18. импорт пиров с невалидной записью — файл не применяется частично
19. импорт пиров, пытающийся перезаписать `bootstrap-admin-peer`
## E. Fix20 production matrix (обязательные сценарии)
1. **Clean Debian 13 minimal**:
- только SSH, без ручной установки зависимостей;
- default `/etc/nftables.conf` stub;
- install проходит полностью;
- `doctor`/`status` показывают рабочее состояние.
2. **Non-systemd container**:
- fail-fast до destructive шагов;
- диагностическое сообщение с причиной capability/systemd.
3. **Foreign nftables**:
- при `HY2XS_FIREWALL_MODE=managed` install/reconfigure блокируются;
- при `HY2XS_FIREWALL_MODE=takeover` создаются backup/rollback guard и apply проходит.
4. **Rollback guard cleanup**:
- после успешного apply/smoke не остаются `hy2xs-fw-rollback-*.timer/.service`.
5. **Partial install + repair**:
- состояние `install-state` фиксирует промежуточную фазу;
- `repair` завершает граф до `installed=true`.
6. **AAAA при IPv4-only**:
- policy строго валидируется preflight;
- soft warning path не используется в production baseline.
7. **Slow-start admin readiness**:
- install не падает на race после restart;
- readiness waiters дожидаются listener/healthz.
8. **Отказ между PHASE 0 и первой мутацией** (сценарий D1):
- `install-state.json` честно показывает `failed`;
- установщик не заявляет, что хост не изменён.
9. **Устаревший DNS после смены IPv4** (сценарий D2):
- `doctor` и `reconfigure` отказывают;
- в выводе присутствуют оба адреса.
## Acceptance criteria
Система принимается, если:
1. production builder на Debian 13 amd64 выдаёт переносимый install package
2. target server не выполняет build step
3. Hysteria2 получена из official upstream
4. HY2XS admin поставлен из install package
5. `post-install.env` отражает фактическое deploy-состояние
6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт
7. оркестратор не требует standalone update / rollback / uninstall subcommands
8. bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke
9. Telegram/access layer не требуется для прохождения install acceptance
10. отсутствует production path для port hopping
11. UI не запускается от root
12. клиентские endpoint не зависят от request `Host`/`hostname`
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. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»