- docs/07: полный порядок staged apply, инвариант снятия guard, объяснение почему окно 45 секунд не обязано покрывать smoke и почему guard не трогает nftables.service, семантическая проверка эффективного firewall; - docs/11: разделы A5e/A5f для новых unit-тестов и серверные сценарии D1e (guard доходит до дедлайна), D1f (конкурентные операции), D1g (успешная операция не оставляет следов транзакции); матрица и acceptance criteria дополнены; - docs/12: разбор отказов "уже выполняется другая операция" и firewall_guard_fired; - docs/13: строки журнала guard в таблице recovery, новый раздел 8a про замок операций; - docs/14 и purge-v0.sh: очистка /run/hy2xs, замка операций и candidate-файлов firewall — /run это tmpfs, но очистка не имеет права требовать перезагрузки; - README: защита от потери доступа при смене firewall и раздел "Одна операция за раз"; - CHANGELOG: шестой проход.
95 KiB
Testing and acceptance
Цель документа
Зафиксировать checklist для новой двухслойной схемы.
Как запускать тесты
# Юнит-тесты и типы оркестратора
cd orchestrator && bun install --frozen-lockfile && bun run check && bun test
# Тесты и статический анализ HY2XS admin
cd apps && go vet ./... && go test ./...
# Проверка типов и сборка frontend
cd apps/frontend && pnpm install --frozen-lockfile && pnpm run verify
# Сверка среды разработки с versions.env (ничего не меняет)
./tools/dev/doctor.sh
# Полный 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
Проверяем
- builder запускается на Debian 13 amd64 build host
- итоговый пакет собирается без target-side шагов
- bundled HY2XS admin реально входит в пакет
- package metadata / build id присутствуют
- compiled Bun/TypeScript orchestrator artifact присутствует
- в пакет не попадает build-мусор
- builder сам доставляет отсутствующие build-зависимости
- builder проверяет версии Go/Bun/Node.js/pnpm
- builder пишет версии toolchain в metadata
- 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и не использует movinglatest— это утверждение проверяется тестом и acceptance-шагом сборки.
A3. Compatibility gate
- скачанный артефакт проходит проверку SHA-256;
hysteria versionсовпадает с разрешённой версией;- реальный бинарник принимает канонический конфиг HY2XS для Gecko;
- то же для Salamander;
- при несовместимости падает сборка с сообщением
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,ensureDirи всеrunMutating*-раннеры; - read-only раннеры под guard'ом продолжают работать: разделение API — это не запрет наблюдения, а запрет мутации;
- классификация отказа зависит от ownership-флагов и фазы, а не от текста ошибки;
fatal_pre_applyнедостижим ни при одном взведённом флаге, включаяstateTouched: записанныйinstall-state.jsonуже делает хост изменённым;- частично выполненная запись маркера (отказ на
chownпосле успешногоwrite) тоже даёт post-apply: флаг взводится до записи, а не после неё; - начатая (не обязательно завершённая) установка пакетов уже даёт
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.
A5a. Обязательный откат (unit)
orchestrator/test/rollback-mandatory.test.ts — поведение механизма проверяется
настоящим внедрением отказа в стадию, проводка команд к нему — разбором
исходника (поднять systemd и nftables в этой среде нельзя):
- при отказе первой стадии отката выполняются все последующие;
- отказавшие стадии перечисляются по именам и в порядке объявления;
- откат не бросает даже при отказе всех стадий: наружу обязана уйти исходная ошибка операции, а не проблема внутри восстановления;
- не-
Errorпричина (брошенная строка) не роняет откат; persistFailureStateне пробрасывает отказ записи наружу — это и был P0: падение записи маркера отменяло откат целиком;- в обработчике ошибки
installиreconfigureне осталось незащищённой записи состояния (advanceInstallState/markPhaseголымawait); - откат в обеих командах идёт через
runRollbackStages, а не цепочкойawait; - внутри
rollbackCurrentStateни одна команда не обрывает следующие: отказsystemctl daemon-reloadотменял перезапуск сервисов строкой ниже, то есть восстановленные unit-файлы так и не применялись.
A5c. Целостность резервных копий (unit)
orchestrator/test/backup-integrity.test.ts:
- копия каждой операции адресуется своим каталогом, разные
op-idне пересекаются; - разные пути дают разные имена файлов копии, и имя не выходит за пределы каталога;
- отсутствовавший файл записан явно (
present: false), а не выведен из неудачиcp; - манифест переживает сериализацию без потерь;
- разбор строгий: манифест чужой операции, неизвестная версия, битый JSON, запись без пути, без признака существования или без имени копии — отклоняются. «Поле не разобралось, будем считать, что файла не было» означало бы удаление существующего файла при откате;
- копирование в
reconfigureи вfirewallне глушит ошибки, факт создания копии проверяется, а копия снимается до первой мутации; - маркер готовности firewall (
prepared) ставится после проверенных копий; - восстановление firewall не глушит ошибки
cp/nft, а резервные копии удаляются только после подтверждённого успеха — иначе сохраняются вместе с сообщениемmanual recovery data preserved at ….
A5d. Порядок фиксации успеха (unit)
orchestrator/test/commit-ordering.test.ts:
- снятие таймера автоотката и удаление резервных копий — разные операции
(
disarmFirewallRollback/cleanupFirewallRollback), объединённаяcancelFirewallRollbackне вернулась ни в один вызов; disarmне удаляет копии;- порядок в
installиreconfigureодинаков:disarm→ долговечная записьinstalled→cleanup; - успешный smoke фиксируется отдельной фазой до снятия таймера;
- уборка после точки фиксации выполняется best-effort.
A5e. Транзакционность rollback guard (unit)
orchestrator/test/firewall-guard.test.ts:
- маркер
auto-rollback-firedсоздаётся rollback-скриптом первым действием — до проверкиpreparedи до первой попытки восстановления, в том числе когда восстанавливать нечего. Без этого «guard сработал» недоказуемо: транзиентные юниты systemd после выполнения исчезают, иsystemctl stopдля них неотличим от успешного снятия взведённого таймера; - скрипт не маскирует ошибки (
|| true,2>/dev/null), не используетset -eи возвращает накопленныйrc: каждый сообщённый отказ поднимает код возврата, поэтому частичное восстановление уходит вfailed, а не в молчаливый0; - скрипт не трогает
nftables.service: у негоExecStop=nft flush ruleset, и остановка сервиса стёрла бы только что восстановленные правила; - скрипт разбирается настоящим shell-парсером. Парсер принимается только после двусторонней проверки — он обязан принять заведомо корректный скрипт и отвергнуть заведомо сломанный, иначе тест ничего не проверяет;
- небезопасный ключ операции отвергается: он служит именем каталога, именем systemd-юнита и подставляется в текст скрипта;
- снятие guard проверяет маркер с обеих сторон остановки, подтверждается
ActiveStateобоих юнитов, и на пути фиксации успеха допускает единственное состояние —inactive; на пути восстановленияfailedтоже допустим; - сработавший guard опознаётся по типу ошибки: ошибка с тем же текстом, но другого типа классифицируется по владению, как и прежде;
- эффективный firewall сверяется семантически (фрагмент, entrypoint,
загруженная таблица), и проверка выполняется read-only раннерами — та же
проверка идёт в
doctor; - состояние
nftables.serviceснимается до первой мутации и восстанавливается стадиями, идущими до применения ruleset; - candidate-файлы убираются после успеха и best-effort при откате;
- ключ операции считается одной функцией: install писал в маркер сырой
ISO-timestamp, и путь
/run/hy2xs/rollback/<op_id>из runbook не существовал.
A5f. Взаимное исключение операций (unit)
orchestrator/test/operation-lock.test.ts:
- второй захват при живом держателе отказывает, и отказ называет держателя — команду, PID и время начала;
- замок снимается в
finallyи после отказа операции: иначе первая же неудачная установка заблокировала бы сервер до перезагрузки; - замок мёртвого держателя переиспользуется, временный файл переиспользования не остаётся на диске;
- непонятое содержимое замка не снимается автоматически: оно не доказывает отсутствие операции, и сомнение трактуется в пользу отказа;
readLockHolderотличает «замка нет» от «замок нечитаем»;- захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся до включения guard, и проверка существует, чтобы перенос захвата внутрь читающей фазы отказал громко, а не записал файл молча;
- политика CLI закреплена структурно:
install/reconfigure/repair/doctorвызываются только под замком,status/diagnosticsего не берут, но сообщают об идущей операции, аpreflight-installотказывает до собственных проверок.
A5b. Долговечная запись маркера (unit)
orchestrator/test/atomic-write.test.ts:
-
содержимое заменяется целиком, а не дописывается поверх прежнего;
-
при отказе записи по целевому пути остаётся прежний полный документ;
-
временный файл не выживает ни при успехе, ни при отказе подстановки;
-
права выставляются точно, независимо от umask (
0600,0644); -
ensureDirприводит права существующего каталога к объявленным:mkdirэтого не делает, поэтому «создать» и «права такие, как объявлено» — два разных действия; -
и запись, и создание каталога проходят через read-only guard;
-
persistInstallStateпод guard'ом отказывает: единственный писатель маркера обязан идти через guarded-примитивы, иначе PHASE 0 смогла бы создать/var/lib/hy2xs, и «read-only» перестало бы быть правдой ровно для того файла, по которому clean-host принимает решение. -
ensureDirсообщает, был ли каталог фактически создан: родитель синхронизируется только при создании, иначе долговечность записиhy2xsв/var/libосталась бы необеспеченной, и после потери питания мог исчезнуть весь каталог вместе с маркером.
Наличие самих fsync проверяется приёмкой сборки по исходнику: из
пользовательского процесса их не наблюдать, а без них rename() даёт
атомарность видимости без долговечности.
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оно не создаёт.
A9e. Жизненный цикл пира установщика (unit, настоящая SQLite)
apps/dao/bootstrap_peer_test.go — проверяется не функция, а поведение сервиса
при перезапуске: дефект, ради которого написан этот файл, проявлялся только на
ВТОРОМ запуске, поэтому каждый тест прогоняет полную последовательность
InitSqlAt дважды на одной базе.
- первый запуск создаёт пира и выставляет отметку
BOOTSTRAP_PEER_SEEDED; - обычный перезапуск не пересоздаёт пира и не плодит дублей (
idтот же, запись ровно одна); - удаление переживает перезапуск: после
DELETEи рестарта пир не возвращается, хотяHY2XS_ADMIN_CON_PASSостаётся в окружении; - то же после трёх перезапусков подряд;
- отключённый пир сохраняет
disabled = 1и свойsecret_digest; - отметка и пир пишутся одной транзакцией: при конфликте
UNIQUE(name)внутри транзакции отметка не остаётся выставленной; - отсутствие
HY2XS_ADMIN_CON_PASSна чистой базе — отказ старта; - перезапуск установленного сервиса без этой переменной проходит штатно;
HYSTERIA2_TRAFFIC_STATS_SECRET: пустой env при пустой базе — отказ старта, сгенерированного токена в базе не появляется; токен, уже согласованный ранее, принимается без переменной.
A9f. Резервная копия пиров (unit)
apps/service/peer_export_backup_test.go:
includeSecrets=trueна исправных данных отдаёт секрет каждого пира;- нерасшифровываемый секрет хотя бы одного пира отклоняет весь запрос, сообщение называет пира, частичное содержимое не возвращается;
- пир вовсе без шифртекста — тот же отказ;
includeSecrets=falseповреждённых данных не замечает и пустойsecretотдаёт штатно: это и есть смысл безопасного режима.
A9g. Слой данных: «нет записи» против «база не ответила» (unit)
apps/dao/config_test.go:
UpdateConfigпо отсутствующей строке — отказ, а не тихий успех: UPDATE без совпавших строк не является ошибкой SQL, и раньше оператор получал подтверждение изменения, которого не произошло, а планировщик тут же получал новое расписание;UpdateConfigне создаёт строк: это работаUpsertConfigValue;- транзакционная партия откатывается целиком, если одна из строк отсутствует;
GetConfig/GetPeerвозвращаютErrConfigNotFound/ErrPeerNotFound, отличимые черезerrors.IsотErrStorage.
apps/cmd/reset_test.go — единственный оставшийся потребитель, который склеивал
эти два ответа:
- на пустой базе
reset-adminсоздаёт учётную запись, bcrypt-хеш подходит к напечатанному паролю,force_password_changeвыставлен; - поверх существующей записи обновление идёт на месте: тот же
id, новый пароль, увеличенныйtoken_version, прежний пароль больше не действует; - при отказе чтения (
ErrStorage) сброс останавливается: вторая учётная запись не создаётся, существующая не меняется, напечатанный пароль не действует. База в этом тесте полностью работоспособна — воспроизводится ровно транзиентный отказ («database is locked»), при котором прежний код уходил в ветку создания и оставлял на сервере вторую рабочую учётку с уже напечатанным паролем; - при
ErrAdminUserNotFoundсоздание по-прежнему выполняется: строгость к отказу хранилища не имеет права сломать штатный путь восстановления; - непригодный для bcrypt пароль останавливает сброс, а не пишет пустую строку
в
password_hash— раньше ошибка хеширования проглатывалась (hash, _ := util.HashPassword(...)), и команда восстановления доступа молча его отбирала:VerifyPasswordотклоняет всё, что не bcrypt; - sentinel'ы разных таблиц несут одинаковый текст (
WrongPasswordуезжает в ответ Hysteria и менять его нельзя), поэтому проверяется именно различимость черезerrors.Is, а не по строке.
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 — это отказ проверки, а не её отрицательный результат.
pnpm audit проверяет весь lock-граф frontend, а не production-подграф:
build tooling исполняется на build-машине и порождает production-бандл, поэтому
уязвимость в нём уезжает в артефакт. Приёмка сборки следит, чтобы --prod не
вернулся в гейт.
A11a. Обязательные тесты (build)
Гейт тестов устроен так же, как гейт зависимостей: аварийного выхода нет, результат виден по готовому артефакту.
| Шаг сборки | Что запускается |
|---|---|
run_orchestrator_tests |
bun x tsc --noEmit, bun test |
bundle_ui |
pnpm run typecheck до сборки bundle |
run_admin_tests |
go vet ./..., go test ./... |
Приёмка проверяет:
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
(место для истории —
CHANGELOG.md); metadata/package.envсодержитtests_gate=true;- утверждение о прогоне выставляется после самого прогона, а не до него;
write_metadataотказывается писать метаданные, если хотя бы один из двух прогонов не подтверждён.
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 проверяем
- пакет запускается без ручной сборки на сервере
- Hysteria2 скачивается с official upstream
- bundled HY2XS admin раскладывается локально из пакета
- создаются нужные каталоги
- создаются systemd unit-файлы
- создаются
hy2xs.envиpost-install.envс правами0600 root:root - baseline firewall применяется корректно через staged mode
- SSH остаётся доступным
reconfigure --dry-runвыводит план измененийreconfigure --applyприменяет изменения и проходит smoke
C. Runtime tests
hysteria-serveractivehy2xs-adminactive- Hysteria слушает только IPv4 (
0.0.0.0:<udp_port>) - HY2XS admin слушает ожидаемый
HY2XS_UI_BIND_HOST:<ui_port> - тестовый совместимый клиент подключается
- идёт реальный трафик
- лимит 50/50 Mbps соблюдается при согласованной клиентской конфигурации
- reboot не ломает baseline
- Hysteria2 управляется systemd unit, а не внутренним updater'ом admin panel
- нет IPv6 listen (
[::]) для Hysteria/HY2XS admin trafficStats.secretне равенJWT_SECRET- bootstrap admin secret существует и имеет
0600 trafficStatsAPI: корректный secret принимает запрос, неверный secret отклоняется- TLS mode в
config.yamlсоответствует runtime env (acme|file|self_signed_dev) - при
HY2XS_TLS_MODE=acmeвconfig.yamlвыставленacme.typeизHY2XS_ACME_TYPE - direct
hysteria2://node URL в API/QR формируется поHY2XS_PUBLIC_HOST+HY2XS_PUBLIC_PORT; subscription delivery endpoint отключён в baseline и не входит в acceptance nft -c -f /etc/nftables.confпроходит после apply- пароль admin и
con_passне перезаписываются при рестартеhy2xs-admin - остановка/рестарт UI не останавливает
hysteria-server - traffic accounting/kick ориентируются на systemd status; ключа
HYSTERIA2_ENABLEв базе больше нет /etc/hysteria/config.yamlимеет0640 hysteria:hy2xs-adminhy2xs-adminможет читать/etc/hysteria/config.yaml, но не может писать- смена расписания сброса трафика применяется без перезапуска
hy2xs-admin, и число джоб планировщика не растёт - невалидное cron-выражение отклоняется API, а значение в базе не меняется
systemctl restart hy2xs-adminзавершает сервис штатно: планировщик остановлен до закрытия SQLite, в журнале нетdatabase is closedgovulncheck ./...на графе релиза не находит вызываемых уязвимостей
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:
- сервер принимает сгенерированный конфиг и стартует;
- TLS handshake;
- handshake с обфускацией;
- HTTP auth HY2XS: разрешённый пир принят;
- HTTP auth HY2XS: неразрешённый пир отклонён;
- клиент подключается именно по ссылке, которую выдаёт production-код;
- TCP forwarding;
- UDP forwarding;
trafficStatsс валидным secret;trafficStatsс невалидным secret отклоняется;- per-peer accounting содержит аутентифицированного пира;
- перезапуск сервера;
- быстрое переподключение клиента (поведение 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. Граница установки на живом сервере
Проверяется на хосте, где уже стоит предыдущая установка:
install.shзавершается отказом на PHASE 0;/usr/local/lib/hy2xsне создан и не изменён;/var/lib/hy2xs/install-state.jsonне перезаписан;hysteria-serverиhy2xs-adminосталисьactive;- в тексте отказа перечислены найденные маркеры и указан
docs/14-legacy-cleanup.md; - после
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 прошла» и «первая мутирующая операция упала» — место, где установщик раньше врал.
- PHASE 0 проходит успешно;
installDepsломается искусственно (например, недоступный apt-репозиторий или временно испорченный/etc/apt/sources.list.d/);- установка завершается отказом;
- в выводе нет
fatal_pre_applyи нет фразы про «ничего не применялось»; /var/lib/hy2xs/install-state.jsonсуществует и честно показываетphase: failedс текстом ошибки;owned_pathsв маркере содержит/usr/local/lib/hy2xs,/usr/local/bin/hy2xs-orchestratorи/usr/local/lib/hy2xs/package— всё, что операция действительно создала;- diagnostics-бандл собран;
hy2xs-orchestrator statusне заявляет установку успешной.
До исправления шаги 4–7 давали противоположный результат: install-state.json
уже лежал на диске, но отказ классифицировался как pre-apply, обработка
состояния пропускалась, а следующая установка на этой машине отказывалась по
clean-host контракту из-за оставшегося маркера.
Пункт 6 закрывает вторую половину той же щели. Пока раскладку оркестратора и
runtime-пакета выполнял install.sh, эти пути не принадлежали никому: они не
попадали в owned_paths, а отказ второго preflight (сменился DNS, занялся
порт, не ответил резолвер) объявлялся fatal_pre_apply — «на сервере ничего не
изменено» — при уже созданном каталоге оркестратора.
D1b. Откат при невозможности записать состояние отказа (fault injection)
Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния больше не стоит перед восстановлением.
Тайминг здесь — часть сценария, и его легко испортить.
tmpfs НЕЛЬЗЯ монтировать заранее: первая же запись маркера (preflight_ok)
получит ENOSPC, установка отвалится до firewall, и проверяться будет совсем
другой путь — обычный fatal_post_apply на ранней стадии.
Порядок строго такой:
- запустить установку и дождаться в журнале
step=firewall status=done; - только теперь, во втором терминале:
mount -t tmpfs -o size=16k tmpfs /var/lib/hy2xs
dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
- вызвать искусственный отказ следующего шага установки.
Ловить это окно руками неудобно, поэтому тот же сценарий имеет смысл прогнать и
через отказ на более длинном шаге (smoke), где времени заметно больше:
дождаться step=smoke checks, смонтировать tmpfs и остановить один из
сервисов, чтобы smoke не сошёлся.
Сценарий:
- установка доходит дальше шага firewall (то есть
firewallTouchedвзведён, правила применены); - следующий шаг ломается искусственно;
- запись
phase: failedв маркер падает поENOSPC; - в журнале есть
failed to persist failure state, continuing with the mandatory rollback; - откат всё равно выполняется:
rollbackFirewallNowснимает применённые правила,/etc/nftables.confвозвращается к прежнему состоянию, а развёрнутые этой операцией юниты останавливаются и выключаются; - SSH остаётся доступным;
- в журнале перечислены отказавшие стадии отката, если они были, и наружу
ушла исходная ошибка операции, а не
ENOSPC.
До исправления шаги 4–6 давали противоположный результат: бросок из записи состояния уносил управление наружу, и сервер оставался с применённым firewall неудавшейся установки.
Тот же сценарий повторяется для reconfigure, где цена выше: там откат
дополнительно возвращает конфиги из /etc/hy2xs/backups, и оба восстановления
отменялись разом.
Дополнительно проверяется независимость стадий: если сделать неработоспособной
первую стадию (например, сделать /etc/nftables.conf неперезаписываемым через
chattr +i между применением firewall и отказом), восстановление конфигов и
остановка сервисов обязаны выполниться всё равно, а в журнале обязаны появиться
rollback stage "…" failed, continuing with the remaining stages и итоговое
rollback finished with N failed stage(s).
D1c. Данные отката переживают отказ фиксации успеха
Проверяется на чистом хосте. Это второй сценарий того же класса, но на противоположном конце операции: отказывает не промежуточный шаг, а запись успеха.
- установка доходит до успешного
smoke, в маркере появляетсяphase: smoke_ok; - сразу после этого
/var/lib/hy2xsделается недоступным для записи (тот жеtmpfs, смонтированный по появлениюstep=smoke checks status=done); - запись
phase: installedпадает; /run/hy2xs/rollback/<op>/preparedи обе резервные копии firewall всё ещё существуют — это и есть проверяемое свойство;- откат выполняется полностью:
/etc/nftables.confвозвращается к прежнему содержимому, развёрнутые юниты останавливаются; - в журнале нет строки
no HY2XS rollback markers found.
До исправления пункты 4–6 давали противоположный результат: снятие таймера и
удаление копий выполнял один вызов, стоявший до записи installed, поэтому
откат запускался, но откатывать ему было нечем.
Обратная проверка — успешный путь: после нормально завершённой установки
/run/hy2xs/rollback/ пуст, а phase: installed записан.
D1d. Отказ снятия резервной копии останавливает reconfigure до мутации
Проверяется на рабочей установке.
/etc/hy2xs/backupsделается недоступным для записи (chattr +iили заполненныйtmpfs);- запускается
reconfigure --apply; - операция отказывает на шаге
backupс сообщением про несозданную копию; /etc/hysteria/config.yaml, unit-файлы и/etc/nftables.confне изменены, сервисы не перезапускались.
Отдельно проверяется привязка копии к операции: после успешного reconfigure
в /etc/hy2xs/backups/ остаётся ровно один каталог — текущей операции — с
manifest.json, и в нём перечислены все семь путей, включая отсутствовавшие с
"present": false.
D1e. Guard доходит до дедлайна — фиксация успеха запрещена
Проверяется на чистом хосте. Это сценарий гонки между автоматическим откатом firewall и успешным smoke.
Окно guard — 45 секунд, и оно намеренно короче худшего случая smoke: на
медленном, но исправном сервере retry-бюджеты дают заметно больше. Раньше это
означало, что автоматический откат мог вернуть прежний firewall, пока smoke
продолжает идти, а единственной проверкой firewall в smoke был nft -c — разбор
текущего файла, каким бы он ни был. Прежний валидный ruleset проходил её
зелёным, и сервер объявлялся успешно настроенным с предыдущим firewall.
Сценарий:
- установка доходит до шага
firewall, в журнале появляетсяfirewall rollback guard armed; - smoke искусственно замедляется дольше 45 секунд. Проще всего задержать один
из сервисов — например, добавить в
hy2xs-admin.serviceвременныйExecStartPre=/bin/sleep 60и выполнитьsystemctl daemon-reloadдо запуска установки; - guard срабатывает: в journal появляется юнит
hy2xs-fw-rollback-<op-id>.service, а на диске —/run/hy2xs/rollback/<op-id>/auto-rollback-fired; - установка обязана завершиться отказом, даже если smoke успел сойтись;
- в маркере установки стоит
phase: firewall_guard_fired, а неinstalled, и неsmoke_failed; installed: trueне записан;- выполняется обычный откат операции: firewall возвращается к прежнему состоянию, развёрнутые этой операцией юниты останавливаются;
- SSH остаётся доступным.
Отдельно проверяется вторая половина того же дефекта — семантический smoke.
Если на рабочей установке подменить /etc/nftables.d/hy2xs.nft на прежний
валидный ruleset и выполнить hy2xs-orchestrator doctor, диагностика обязана
отказать с сообщением про несовпадение эффективного firewall, а не пройти по
nft -c.
D1f. Конкурентная операция отказывает до первой мутации
Проверяется на рабочей установке. Проверяемое свойство — отказ происходит до снятия резервной копии и до первой мутации, а не в середине транзакции.
- запускается длинный
reconfigure --apply(например, с задержкой вExecStartPre, как в D1e); - во втором терминале, пока первый идёт, запускается второй
reconfigure --apply; - второй отказывает сразу, с текстом
another HY2XS operation is already in progress: reconfigure (pid …); /etc/hy2xs/backups/не пополнился каталогом второй операции;/etc/hysteria/config.yaml, unit-файлы и/etc/nftables.confизменены ровно один раз — первой операцией;/run/hy2xs/rollback/содержит каталог только первой операции.
Те же проверки для пар:
install идёт -> doctor отказывает
install идёт -> install.sh отказывает на PHASE 0, до собственных проверок
reconfigure идёт -> repair отказывает
И обратная проверка — наблюдающие команды не блокируются:
reconfigure идёт -> hy2xs-orchestrator status
→ выполняется
→ в отчёте operation_in_progress = "reconfigure (pid …)"
→ human_status предупреждает, что это снимок незавершённой транзакции
reconfigure идёт -> diagnostics collect
→ выполняется
→ в stderr есть note об идущей операции
Отдельно проверяется, что замок не переживает своего держателя:
reconfigure --applyпрерываетсяCtrl+C— замок снят, следующийreconfigureпроходит;- процесс убивается
kill -9, после чего следующая операция сообщаетis held by … which is no longer running; reclaiming itи продолжает; /run/lock/hy2xs-orchestrator.lockне остаётся после завершения операции.
D1g. Успешная установка не оставляет следов транзакции
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка к D1c и D1e: она ловит противоположную ошибку — данные транзакции, пережившие её завершение.
После installed:
systemctl list-units --all 'hy2xs-fw-rollback-*' → пусто
ls /run/hy2xs/rollback/ → пусто
ls /run/lock/hy2xs-orchestrator.lock → отсутствует
ls /etc/nftables.conf.candidate → отсутствует
ls /etc/nftables.d/hy2xs.nft.candidate → отсутствует
и /var/lib/hy2xs/install-state.json содержит phase: installed,
installed: true, а op_id в нём совпадает с именем каталога, который лежал в
/run/hy2xs/rollback/ во время установки.
/etc/nftables.conf.candidate — прямая регрессия: он не удалялся вообще, и
успешная установка оставляла его на сервере навсегда.
D1a. Проход установки не спотыкается о собственный маркер
Проверяется на чистом хосте, обычной успешной установкой.
install.shдоходит доpreflight capabilitiesпослеapt-get;- установка на этом шаге не падает с текстом «обнаружена предыдущая или посторонняя установка»;
- установка доходит до
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
- не Debian 13
- порт уже занят
- старое конфликтующее состояние уже существует
- домен / SNI заданы некорректно
- bundled UI отсутствует в пакете
- Hysteria upstream недоступен
- firewall применился частично
- install flow прерван посередине
- попытка использовать
HY2XS_IPV6_ENABLED=true HY2XS_PUBLIC_HOST=0.0.0.0- неизвестный
HY2XS_HYSTERIA_OBFS_TYPE - конфигурация со схемой
HY2XS_CONFIG_SCHEMA_VERSIONиз линейки0.x - upstream
latestнесовместим с шаблоном HY2XS — падает сборка, не установка HY2XS_PUBLIC_HOSTрезолвится не на этот серверHY2XS_DOMAINрезолвится не на этот сервер при отличном от негоPUBLIC_HOST- A-запись содержит правильный адрес и чужой одновременно
- неизвестное значение
HY2XS_PUBLIC_ENDPOINT_POLICY - импорт пиров с невалидной записью — файл не применяется частично
- импорт пиров, пытающийся перезаписать
bootstrap-admin-peer
E. Fix20 production matrix (обязательные сценарии)
-
Clean Debian 13 minimal:
- только SSH, без ручной установки зависимостей;
- default
/etc/nftables.confstub; - install проходит полностью;
doctor/statusпоказывают рабочее состояние.
-
Non-systemd container:
- fail-fast до destructive шагов;
- диагностическое сообщение с причиной capability/systemd.
-
Foreign nftables:
- при
HY2XS_FIREWALL_MODE=managedinstall/reconfigure блокируются; - при
HY2XS_FIREWALL_MODE=takeoverсоздаются backup/rollback guard и apply проходит.
- при
-
Rollback guard cleanup (сценарий D1g):
- после успешного apply/smoke не остаются
hy2xs-fw-rollback-*.timer/.service; /run/hy2xs/rollback/,/run/lock/hy2xs-orchestrator.lockи*.candidateне переживают успешную операцию.
- после успешного apply/smoke не остаются
4a. Guard доходит до дедлайна (сценарий D1e):
auto-rollback-firedсоздан, операция завершается отказом сphase: firewall_guard_fired;installed: trueне записан, даже если smoke успел сойтись.
4b. Конкурентные операции (сценарий D1f):
- вторая операция отказывает до снятия резервной копии и первой мутации;
status/diagnosticsне блокируются и сообщают об идущей операции;- замок не переживает своего держателя.
-
Partial install + repair:
- состояние
install-stateфиксирует промежуточную фазу; repairзавершает граф доinstalled=true.
- состояние
-
AAAA при IPv4-only:
- policy строго валидируется preflight;
- soft warning path не используется в production baseline.
-
Slow-start admin readiness:
- install не падает на race после restart;
- readiness waiters дожидаются listener/healthz.
-
Отказ между PHASE 0 и первой мутацией (сценарий D1):
install-state.jsonчестно показываетfailed;- установщик не заявляет, что хост не изменён.
-
Устаревший DNS после смены IPv4 (сценарий D2):
doctorиreconfigureотказывают;- в выводе присутствуют оба адреса.
Acceptance criteria
Система принимается, если:
- production builder на Debian 13 amd64 выдаёт переносимый install package
- target server не выполняет build step
- Hysteria2 получена из official upstream
- HY2XS admin поставлен из install package
post-install.envотражает фактическое deploy-состояние- оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт
- оркестратор не требует standalone update / rollback / uninstall subcommands
- bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke, и ни один его собственный отказ не отменяет остальные стадии
- Telegram/access layer не требуется для прохождения install acceptance
- отсутствует production path для port hopping
- UI не запускается от root
- клиентские endpoint не зависят от request
Host/hostname - production build verify падает, если
config/hy2xs.envсодержит placeholder-значения - production build verify падает при dirty git tree (кроме
ALLOW_DIRTY_BUILD=true) - metadata содержит
source_git_commit,dirty_tree,build_profile=production - builder без override на сегодняшний день автоматически выбирает последнюю стабильную версию Hysteria
- собранный пакет содержит точные версию, URL и SHA-256
- выход новой версии Hysteria после сборки не меняет содержимое старого пакета
- новая установка генерирует Gecko
- Gecko использует
512/1200 - установленная Hysteria реально принимает сгенерированный YAML
- сервис запускается под существующим непривилегированным пользователем
hysteria - созданный пользователь получает
hysteria2://сobfs=geckoиobfs-password - совместимый клиент Hysteria подключается напрямую по этой ссылке
- после перезапуска Hysteria клиент быстро восстанавливает соединение
- режим
HY2XS_HYSTERIA_OBFS_TYPE=salamanderполностью работоспособен - admin читает Gecko-конфиг без ошибок
- экспорт не уничтожает современные и неизвестные upstream-поля
- экспорт не содержит секретов
- frontend отображает Gecko
namedotcomудалён, актуальные ACME-провайдеры отражены- документация нигде не утверждает, что Salamander — фиксированный инвариант
- документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
- форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
hy2xs-orchestrator doctorне перезапускает сервисы и не рвёт живые соединения, и это обеспечено read-only guard'ом, а не соглашением о выборе раннера- удаление
bootstrap-admin-peerпереживаетsystemctl restartиreboot: пир не воскресает - отключённый
bootstrap-admin-peerостаётся отключённым после перезапуска - резервная копия с
includeSecrets=trueзавершается ошибкой целиком, если секрет хотя бы одного пира недоступен - админка не генерирует
HYSTERIA2_TRAFFIC_STATS_SECRETсама: пустой env при пустой базе — отказ старта - проверка зависимостей на уязвимости не имеет обходов ни в сборке, ни в документации, и покрывает весь lock-граф frontend
apps/go.modобъявляетtoolchain, совпадающий сGO_VERSIONизversions.envtools/dev/doctor.sh/doctor.ps1показывают расхождение среды разработки сversions.env- маршруты-алиасы
/:id/client-urlи/:id/qrудалены и не входят в публичный API v1 pnpm run typecheck(vue-tsc --noEmit) проходит без ошибок и является обязательным шагом сборки- проверка типов идёт до сборки bundle, а не после
vue-tscверсии 3 и выше: 0.x проверку шаблонов не выполняетpnpm auditпо всему графу зависимостей frontend не находит уязвимостей- локальные SVG-иконки собираются спрайтом из репозитория, без
vite-plugin-svg-icons - каждая иконка задаёт систему координат:
viewBoxлибо параwidth/height - страница конфига Hysteria не содержит элементов управления, которые ничего не сохраняют
- невозможность записать состояние отказа не отменяет откат: восстановление выполняется, в журнале остаётся отметка о неудавшейся записи
install-state.jsonпишется одним писателем, атомарно и сfsyncфайла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный- ownership-флаг маркера установки взводится до записи, поэтому отказ на
chownне даётfatal_pre_applyпри уже созданном файле - тесты и проверка типов не имеют обходов ни в сборке, ни в документации;
metadata/package.envсодержитtests_gate=true, и это утверждение опирается на фактический прогон reset-adminпри недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустомуpassword_hash- данные для отката переживают долговечную фиксацию успеха: снятие таймера автоотката и удаление резервных копий разделены записью
phase: installed - резервная копия снимается строго и до первой мутации; несозданная копия останавливает операцию, а не игнорируется
- копия привязана к операции: откат восстанавливает состояние непосредственно перед текущим проходом, а не сохранённое предыдущим
- ни одна команда отката не глушит свой код возврата; отказавшие стадии перечисляются, а артефакты восстановления удаляются только после подтверждённого успеха
doctorне выполняет проб, изменяющих данные в админке: авторизация действующим паролем пира ограничена режимомinstall- снятие rollback guard доказывается, а не объявляется: отсутствие маркера
auto-rollback-firedиActiveState=inactiveобоих юнитов — предусловие записиphase: installed - сработавший guard запрещает фиксацию успеха, каким бы ни был результат smoke, и получает собственную причину отказа
firewall_guard_fired - smoke сверяет эффективный firewall с конфигурацией операции, а не только разбирает
/etc/nftables.conf - автоматический откат firewall сообщает о частичном восстановлении отказом юнита, а не молчаливым кодом 0, и сохраняет данные восстановления
- откат восстанавливает
enabled/activeсостояниеnftables.service, а не только файлы правил - операции жизненного цикла сериализованы эксклюзивным замком: вторая операция отказывает до первой мутации, а
status/diagnosticsне блокируются - замок снимается при любом завершении держателя, включая
Ctrl+C, SIGTERM и обрыв SSH; замок мёртвого держателя переиспользуется безопасно