Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от 64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция пакета до v0.28. Следствие: пароль из 64 кириллических букв (128 байт) проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на штатной смене пароля, а при установке падением старта админки, то есть сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта было то, что тест закреплял это значение как ожидаемое. Вместе с ним закрыты три соседних расхождения того же контракта. Пароль триммился вопреки собственному контракту. util.HashPassword вёл проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение "abcde " принимали все двери продукта и не мог захешировать никто, а первая учётная запись создавалась не с тем паролем, который оператор записал в hy2xs.env. Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы async-validator, а он сравнивает min/max с String.prototype.length: пароль из трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера, который панель не могла объяснить. hy2xs.env не был форматом. Значения писались интерполяцией, а читались split("=") с trim(); при этом файл читает не только оркестратор — он объявлен EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd срезает краевые пробелы и трактует обратный слеш как escape. Что сделано: - контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword и dao, а service импортирует util — обратный импорт был бы циклическим, и именно поэтому HashPassword завёл собственную копию правила; - AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в оркестраторе и панели; сверяется тестами, читающими Go-исходник; - одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в байтах тегом валидатора не выразить) и код причины admin_password_format, называющий обе границы; - TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет контракт сам и падает с текстом, называющим переменную и файл; - панель считает code points и UTF-8 байты общим adminPasswordFormRule на обеих формах вместо встроенных min/max; - orchestrator/src/lib/envFile.ts — порт конечного автомата parse_env_file_internal из systemd и обратный ему кодировщик; экранируются только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же кодировщиком пишется bootstrap-admin.secret; - управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а ввести такой пароль в форму входа всё равно нельзя; - отрицательная проба smoke сверяет конверт отказа (code 50000, invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль генерирует, а не берёт из литерала; - положительная проба читает bootstrap-секрет парсером формата вместо grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы. Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я», «abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами инвертирован; round-trip env-формата на значениях с кавычками, слешами и краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов приёмки. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
71 KiB
A. Тесты слоя сборки
Часть набора проверок HY2XS. Карта всех частей — docs/testing/README.md.
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-конфига попал литерал вместо значения из конфигурации».
A4a. Формат env-файла совпадает с systemd
orchestrator/test/env-file.test.ts. У hy2xs.env два читателя, и один из них
не наш: файл объявлен EnvironmentFile= в юните hy2xs-admin. Поэтому
проверяется не «работает на наших данных», а совпадение с правилами systemd
(src/basic/env-file.c) на тех значениях, где прежний разбор расходился:
| Вход | Ожидание |
|---|---|
KEY= value |
value — незакавыченное значение теряет краевые пробелы, как и у systemd |
KEY=" value " |
value — в кавычках сохраняются |
KEY="a\"b" |
a"b |
KEY="a\\b" |
a\b |
KEY="a\nb" |
a\nb — n не входит в SHELL_NEED_ESCAPE, слеш сохраняется |
KEY="$HOME" |
$HOME — подстановок в env-файле нет |
KEY='a\b' |
a\b — в одинарных кавычках escape нет вовсе |
строка без = |
отказ (единственное намеренное расхождение: systemd её отбрасывает молча) |
| незакрытая кавычка | отказ |
И обратимость: любое значение — с краевыми пробелами, кавычками, обратными
слешами, $, `, #, эмодзи — переживает render -> parse побайтово, а
обычные значения (8080, /etc/hysteria/server.crt, 50 mbps) остаются без
кавычек, чтобы релизные гейты и инструкции оператора продолжали работать.
Управляющий символ в значении — отказ ЗАПИСИ, а не потеря части секрета.
Рендер конфига (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; rcобъявляется до создания маркера, а ранний выход возвращает его, а не жёсткий0. Инвариант фиксации верен только при условии «guard способен записать маркер»: покаrc=0стояло после, отказ записи (заполненный tmpfs/run, read-only ФС) не влиял ни на что — скрипт восстанавливал прежний firewall, завершался нулём, и операция фиксировала успех после реально сработавшего отката. Теперь у факта два канала: маркер и отказ юнита;- маркер создаётся
touch, а не: >file: двоеточие — special builtin POSIX, и ошибка перенаправления на нём обязана завершить неинтерактивный shell целиком, то есть в dash скрипт умер бы до восстановления firewall; - поведенчески проверяется ранний путь скрипта — он заканчивается до первой команды восстановления и потому безопасен для запуска: при доступном каталоге маркер создаётся и выход нулевой, при недоступном — выход ненулевой;
- скрипт не трогает
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 не существовал; - команда взведения guard проверяется как значение, а не грепом по
исходнику:
buildArmGuardArgvвозвращает готовый argv, и тест сверяет его целиком — имя юнита с явным суффиксом,--on-active=45s,--timer-property=AccuracySec=1s,--timer-property=RemainAfterElapse=no. Небезопасный ключ операции отвергается до запуска: shell в этой команде не участвует, поэтому единственная защита — отказ; - барьер покоя проверяется поведенчески, с подставляемым наблюдателем
systemd (
SystemdUnitProbe), без systemd и без Linux:- взведённый таймер (
active/waiting) и идущий прямо сейчас откат (activating) запрещают операцию сPendingRecoveryError; - отказ
list-unitsи отказshowна любом отдельном юните даютGuardStateUnknownError: отсутствие ответа systemd — отсутствие наблюдения, а не наблюдение покоя. Прежний тест закреплял обратное (return [];в тексте функции) и потому пережил инверсию смысла: строка была на месте, а решение стало неверным; - оба отказа имеют общего предка
OperationBarrierError; - покой — ровно
inactiveиfailed;maintenance,refreshingи любое незнакомое состояние блокируют операцию, потому что политика перечисляет безопасные состояния, а не опасные; *.timerвSubState=elapsedсчитается покоем:TIMER_ELAPSEDв systemd отображается вUNIT_ACTIVE, и без этой ветки отработавший таймер блокировал быrepairнавсегда. У сервиса тот жеSubStateничего не значит;- разбор вывода
list-unitsнаходит имя юнита и когда первой колонкой идёт маркер●(состояниеfailed) — иначе терялся бы именно аварийно сработавший guard.
- взведённый таймер (
A5f. Взаимное исключение операций (unit)
orchestrator/test/operation-lock.test.ts:
- второй захват при живом держателе отказывает, и отказ называет держателя — команду, PID и время начала;
- замок снимается в
finallyи после отказа операции: иначе первая же неудачная установка заблокировала бы сервер до перезагрузки; - замок мёртвого держателя переиспользуется, временный файл переиспользования не остаётся на диске;
- непонятое содержимое замка не снимается автоматически: оно не доказывает отсутствие операции, и сомнение трактуется в пользу отказа;
readLockHolderотличает «замка нет» от «замок нечитаем»;- захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся до включения guard, и проверка существует, чтобы перенос захвата внутрь читающей фазы отказал громко, а не записал файл молча;
- барьер покоя вызывается дважды — до захвата и уже под замком, — а отказ второй проверки снимает замок за собой. Замок сам по себе гарантии не даёт: он защищает production paths, пока жив держатель, а rollback 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 строка в поле хеша отклоняется;
HashPasswordследует ОБЩЕМУ контракту, а не собственной проверке. Раньше здесь стоялоlen(strings.TrimSpace(password)) < 6— третья копия правила, и она расходилась с двумя остальными: значениеabcdeсчиталось корректным всеми дверями продукта и не могло быть захешировано, а верхней границы у копии не было вовсе;- всё, что контракт принял, обязано хешироваться. Проверяются граничные
значения (36 x
я= 72 байта, 18 x😀= 72 байта): именно здесь расхождение с bcrypt и проявлялось.
A9c1. Контракт учётных данных администратора (unit)
Контракт объявлен один раз в apps/credential/admin.go — в leaf-пакете, потому
что его зовут и util.HashPassword, и слой данных при создании первой учётной
записи, а service импортирует util.
apps/credential/admin_test.go — сам контракт:
-
набор символов логина закреплён ФАКТИЧЕСКИМ множеством: сужение уронит тест, а не вход администратора на живом сервере;
-
границы пароля проверяются таблицей, и она включает случаи, где границы в символах и в байтах расходятся:
значение символов байт результат 64 x a64 64 принят 36 x я36 72 принят (граница bcrypt) 37 x я37 74 отвергнут 18 x 😀18 72 принят (граница bcrypt) 19 x 😀19 76 отвергнут 64 x я64 128 отвергнут последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался;
-
пробел по краям — часть пароля, шесть пробелов являются корректным паролем;
-
управляющие символы (
\n,\r,\t,NUL,DEL) отвергаются.
apps/controller/validator_test.go — ПРОВОДКА, а не контракт: теги
credentialStr и adminPassword прогоняются через production-валидатор и
обязаны отвечать так же, как функции контракта, на тех же граничных значениях.
apps/controller/auth_test.go:
- ни один тег валидации ни в одном DTO не ссылается на незарегистрированное правило (обход исходников, а не проверка одного экземпляра);
- границы пароля не стоят рядом с правилом: тег умеет считать только символы, а у пароля есть ещё граница в байтах, которую тегом не выразить;
- пароль в 72 байта пускает в панель, а на символ длиннее — получает конверт
валидации с причиной
admin_password_formatна полеpass, а не системную ошибку из bcrypt; - пароль не триммится:
bootstrap-passwordиbootstrap-password— разные пароли.
apps/dao/bootstrap_admin_test.go — bootstrap-путь на настоящей SQLite:
- пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом, и вход обрезанным значением невозможен;
- пароль вне контракта роняет старт с текстом, называющим переменную и файл, а не сообщением bcrypt;
- пароль ровно в 72 байта проходит установку целиком.
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отклоняются; - корректный одиночный документ доходит до базы и создаёт пира.
A10a. Отзыв учётных данных и сходимость сессий (unit)
apps/service/peer_secret_rotation_test.go — контракт «новое поколение
credentials получает новую идентичность сессий». Проверяется поведение, а не
наличие поля:
- сессия, установленная по отозванному секрету ПОСЛЕ успешного
/kick, завершается очередным циклом учёта. Сценарий воспроизводится буквально: авторизация по старому секрету удерживается внутриGET /online, за это время выполняется полная ротация с/kick→ 200, затем авторизация отпускается и возвращает старыйauthId— то есть соединение регистрируется уже после разрыва. Ни одна операция при этом не отказала; сходимость даёт то, что старое поколение стало orphan; - то же после
/kick→ 500; - повторная отправка того же секрета рвёт сессию (повтор отзыва), но
идентичность не меняет:
secret_digestтот же; - секрет из одних пробелов не меняет ничего — ни digest, ни
auth_id, ни сессии; прежде правило было записано двумя разными условиями, и такой секрет записывался бы в базу, не разрывая сессий; - импорт: новый секрет при прежнем
authIdв файле и при совпадении по имени ротирует идентичность; свой новыйauthIdиз файла не подменяется; повторный импорт того же файла идентичность не трогает; newPeerAuthIDвыдаёт идентификатор той же формы, что и создание пира, и проходит собственную проверку продукта (peerAuthIDPattern).
apps/service/cron_test.go дополнительно доказывает, что старое поколение
уходит в /kick именно как сессия без строки в базе.
A10b. Правдивая диагностика (unit)
apps/service/hysteria2_state_test.go — матрица двух независимых источников:
| systemd | Traffic Stats API | что обязано быть показано |
|---|---|---|
active |
отвечает | служба работает, API доступен, картина подключений реальная |
inactive |
молчит | оба факта согласованы |
unknown |
отвечает | «состояние неизвестно» + API доступен + картина подключений реальная |
active |
отказывает | служба работает, API недоступен, состояние данных — error |
Третья строка — главная регрессия: прежний путь показывал здесь «служба остановлена» и «API доступен» одновременно, причём второе — не сходив в API.
Отдельно проверяется разбор ответа systemctl is-active: active, inactive,
failed, activating, deactivating, пробелы, многострочный вывод, пустой
ответ и незнакомое слово — последнее означает unknown, а не «остановлена».
apps/service/peer_access_test.go — PagePeer отвечает
onlineState: unavailable и не теряет список пиров, когда Traffic Stats API
недоступен; при доступном API признак ok, а online/onlineDevices в строках
отражают фактический ответ.
apps/util/exec_probe_test.go — ExecProbe отличает ненулевой код возврата
(ответ команды) от невозможности запустить процесс. Требует рабочего bash,
поэтому вне Linux пропускается.
A10c. Журнал Hysteria в фактическом формате upstream (unit)
apps/service/journal_test.go — записи собираются так же, как их пишет zap с
EncoderConfig upstream:
- числовое
time(epoch millis, дробное —EpochMillisTimeEncoderделит наносекунды на миллисекунду) разбирается; прежний разбор падал на каждой такой строке и показывал оператору сырой JSON; - структурный контекст (
addr,id,error,listen,tx, …) сохраняется и дописывается к сообщению в устойчивом (алфавитном) порядке; - числа печатаются без экспоненты, вложенные объекты — компактным JSON в одну строку;
- секреты вырезаются и из сообщения, и из контекста, а адрес остаётся читаемым;
- не-JSON строка и JSON без
msgне теряются; - запись без собственных
level/timeдобирает их из journald; MESSAGE, отданный journald массивом байт (сообщение не является корректным UTF-8), больше не выбрасывает всю запись.
A10d. Проекция конфига на production-профиль (unit)
apps/service/hysteria2_profile_test.go:
- канонический конфиг оркестратора читается целиком и расхождений не даёт;
- отсутствующая секция остаётся отсутствующей — в частности,
trafficStatsне превращается в выдуманный:9999; - явное
falseотличается от «не задано»; - секции вне профиля перечисляются поимённо и по порядку, включая неизвестные HY2XS — они считаются по сырому YAML, а не по типизированной модели;
- ответ, сериализованный так, как его получит браузер, не содержит ни пароля обфускации, ни секрета Traffic Stats API, ни machine token, ни учётных данных outbound; при этом диагностические факты сохранены («пароль задан», адрес auth-URL, имена параметров ACME DNS);
- отсутствующий файл конфига — отказ, а не пустой профиль.
tools/test/frontend-contract.test.ts закрепляет ту же границу со стороны
панели: имя ACME DNS-провайдера показывается как фактическое значение профиля
(text(profile.acme.dnsProvider)), тип поля — обычная строка, а собственного
списка провайдеров в панели нет. Проверка перечисляет их поимённо
(cloudflare, duckdns, …, namedotcom) и требует, чтобы ни один не
встречался в исходнике страницы: реестр в UI был бы вторым экземпляром
upstream-списка, который умеет от него отстать — ровно так namedotcom
пришлось выпиливать вручную после его удаления в Hysteria 2.11.0.
apps/service/hysteria2_export_test.go дополнен якорями YAML: секрет за
&anchor/*alias вырезается и по ссылке, и в самом объявлении; URL с учётными
данными за якорем — тоже; ссылка на составной узел редактируется целиком;
рекурсивная ссылка (alias на предка) не зацикливает санитайзер и не отказывает —
yaml.v3 строит на ней действительно циклический граф узлов.
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 на таблице маршрутов собранного роутера —
и существование этого теста само проверяется контрактом.