Files
HY2XS_flamy/docs/11-testing-and-acceptance.md
T
founder b99be7d514 fix(v1): разблокировать сборку, починить жизненный цикл cron и закрыть каналы утечки
Сборка не собиралась: два контракта приёмки роняли её на корректном коде.

verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.

Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.

Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.

Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.

updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.

Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.

Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
  который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
  достижима с неаутентифицированного запроса; набор алгоритмов подписи
  зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
  проверявшая меньше, чем middleware.

Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
2026-08-29 21:37:50 +05:00

55 KiB
Raw Blame History

Testing and acceptance

Цель документа

Зафиксировать checklist для новой двухслойной схемы.

Как запускать тесты

# Юнит-тесты и типы оркестратора
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 на сборке»:

Сегодня:  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;
  • удалённые ключи (HYSTERIA2_ENABLE, HYSTERIA2_CONFIG, HYSTERIA2_TRAFFIC_TIME, HYSTERIA2_CONFIG_REMARK) отклоняются как неизвестные — проверка идёт по строковым литералам, потому что соответствующих констант в коде уже нет и появиться они не должны.

Атомарность партии проверяется на настоящей SQLite: без базы утверждение «партия не применилась частично» бессмысленно, поскольку предметом утверждения является именно состояние базы.

  • разрешённый ключ первым, запрещённый вторым → запрос отклонён, значение первого ключа в базе не изменилось;
  • невалидное cron-выражение → отказ, значение в базе не изменилось;
  • один ключ дважды в партии → отказ (какое из двух значений считать намерением оператора, определить нельзя);
  • корректная партия → значение сохранено, расписание применено к планировщику, число его записей не выросло.

Порядок в первом тесте принципиален. Предыдущая версия ставила запрещённый ключ первым и до второго элемента не доходила, поэтому проходила и на реализации, которая проверяла и записывала настройки в одном цикле.

A9b. Планировщик (unit)

apps/service/cron_scheduler_test.go — планировщик как собственность процесса:

  • четыре последовательные смены расписания не увеличивают число записей планировщика (главная регрессия: раньше каждая смена добавляла целый дублирующий набор джоб, а старое расписание продолжало работать);
  • пустое выражение снимает джобу сброса, непустое возвращает её — без перезапуска процесса;
  • невалидное выражение не меняет планировщик и не снимает действующую джобу;
  • набор валидных и невалидных выражений проверяется тем же парсером, что и runtime: то, что cron умеет, обязано приниматься, остальное — отклоняться;
  • невалидное значение в базе не роняет старт: на панели висит /internal/hysteria/auth, и отказ старта из-за строки расписания положил бы подключения пользователей. Фиксированные джобы поднимаются, сброс отключён, в журнале ERROR, и настройка чинится через API без перезапуска;
  • второй InitCron поверх работающего отклоняется;
  • StopCron идемпотентен и оставляет планировщик пустым.

A9c. Токены и пароли (unit)

apps/service/jwt_test.go:

  • round-trip: claims, включая token_version, переживают выписку и разбор;
  • токен, подписанный другим HMAC-алгоритмом тем же ключом, отклоняется (прежний keyfunc не смотрел на token.Method вовсе);
  • токен без exp отклоняется, истёкший отклоняется отдельным сообщением;
  • токен с чужим issuer отклоняется даже при совпадении ключа;
  • пустой JWT_SECRET — отказ и на выписку, и на разбор, а не подпись ключом нулевой длины.

apps/util/encrypt_test.go:

  • HashPassword выдаёт bcrypt и солит: два хеша одного пароля различаются;
  • вход по несолёному SHA-224 (формат предыдущего поколения) невозможен;
  • любая не-bcrypt строка в поле хеша отклоняется.

apps/util/rand_test.go — отсутствие modulo bias: на выборке 200 000 символов частоты первых восьми символов алфавита не отличаются от остальных более чем на 5%. Прежняя реализация давала здесь отношение 1.25.

A9d. Пир установщика (unit)

apps/service/peer_bootstrap_guard_test.go — защита действует во всех путях записи, а не только в импорте:

  • смена секрета и переименование bootstrap-admin-peer отклоняются, состояние в базе не меняется;
  • переименование обычного пира в зарезервированное имя отклоняется;
  • создание пира с зарезервированным именем отклоняется;
  • отключение и изменение квоты разрешены;
  • удаление разрешено: это осознанное действие оператора, и расхождения между базой и bootstrap-admin.secret оно не создаёт.

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>, верхний регистр, противоречивые записи, отсутствие нужной строки.

A11. Проверка зависимостей на уязвимости (build)

tools/build/lib/security.sh — обязательный шаг между тестами админки и записью metadata. Подробности в docs/02; здесь важно поведение при отказе:

Код govulncheck Трактовка
0 чисто
3 найдены вызываемые уязвимости → сборка падает
иное отказ самого инструмента → сборка падает отдельным сообщением

Последняя строка существенна: ненулевой код неизвестной природы нельзя трактовать как «уязвимостей нет». По той же причине недоступность реестра npm для pnpm audit — это отказ проверки, а не её отрицательный результат.

A12. Приёмка проверяет код, а не упоминания

Два контракта приёмки на снимке до этого патча гарантированно роняли сборку на корректном коде, и оба — по одной причине: они искали подстроку там, где подстрока обязана присутствовать.

Проверка Что ловила на самом деле
verify_api_namespace_contract grep -rlF '/hui' возвращал apps/router/router_test.go (регрессионный тест, который ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать его отсутствие) и сам versions.sh, где эта строка стоит в тексте проверки
«peer import не выходит за транзакцию» source.slice(start) брал файл от начала applyPeerImportEntry и до конца, захватывая ExistPeerName и UpdatePeerLastConnectionAt — обычные операции вне импорта, которым глобальное соединение положено

Первая падала на шаге versions contract — шестым из четырнадцати, до резолва Hysteria. Вторая не была замечена только потому, что сборка до неё не доходила.

Отсюда правило и помощники code_without_comments / code_mentions_in в acceptance.sh: проверка смотрит на код, а комментарий, объясняющий, почему чего-то больше нет, обязан называть это по имени и не должен ломать сборку. Отсутствие legacy-маршрута доказывает не grep по исходникам, а TestRouterHasNoLegacyNamespace на таблице маршрутов собранного роутера — и существование этого теста само проверяется контрактом.

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; ключа HYSTERIA2_ENABLE в базе больше нет
  21. /etc/hysteria/config.yaml имеет 0640 hysteria:hy2xs-admin
  22. hy2xs-admin может читать /etc/hysteria/config.yaml, но не может писать
  23. смена расписания сброса трафика применяется без перезапуска hy2xs-admin, и число джоб планировщика не растёт
  24. невалидное cron-выражение отклоняется API, а значение в базе не меняется
  25. systemctl restart hy2xs-admin завершает сервис штатно: планировщик остановлен до закрытия SQLite, в журнале нет database is closed
  26. govulncheck ./... на графе релиза не находит вызываемых уязвимостей

C1. Семантический smoke конфига

Недостаточно grep по YAML: он не отличит нужное поле от такой же строки в другой секции и не заметит оставшийся рядом лишний подблок.

Smoke разбирает /etc/hysteria/config.yaml и сверяет с production-профилем:

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_DOMAINHY2XS_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 провайдером

Проверяется на рабочей установке.

сервер:  текущий публичный 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. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»