bundle_ui запускает `pnpm run typecheck` перед сборкой bundle. И наличие шага, и его порядок закреплены приёмкой — вместе с требованием vue-tsc версии 3 и выше и с запретом снова совмещать сборку и проверку в build:prod. Из docs/02 убран раздел «Известное ограничение: проверка типов frontend почти ничего не проверяет» и заменён описанием действующего контракта. Прогноз в нём был близок, но неточен: ошибок оказалось 142, а не ~155, и класс DefaultRow/ PeerVo на Element Plus 2.3 не существовал вовсе — он появился вместе с обновлением Element Plus. docs/04 получил описание модели отображения (третий слой рядом с типизированной моделью и сырым YAML) и раздел о том, что страница Hysteria теперь read-only на всех уровнях, а не только визуально. docs/11: команды проверки frontend и dev doctor в раздел запуска, семь новых пунктов приёмки.
61 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и все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оно не создаёт.
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.
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 проверяем
- пакет запускается без ручной сборки на сервере
- 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 — «на сервере ничего не
изменено» — при уже созданном каталоге оркестратора.
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:
- после успешного apply/smoke не остаются
hy2xs-fw-rollback-*.timer/.service.
- после успешного apply/smoke не остаются
-
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не перезапускает сервисы и не рвёт живые соединения- удаление
bootstrap-admin-peerпереживаетsystemctl restartиreboot: пир не воскресает - отключённый
bootstrap-admin-peerостаётся отключённым после перезапуска - резервная копия с
includeSecrets=trueзавершается ошибкой целиком, если секрет хотя бы одного пира недоступен - админка не генерирует
HYSTERIA2_TRAFFIC_STATS_SECRETсама: пустой env при пустой базе — отказ старта - проверка зависимостей на уязвимости не имеет обходов ни в сборке, ни в документации
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 не содержит элементов управления, которые ничего не сохраняют