Files
HY2XS_flamy/docs/testing/11-3-target-and-runtime.md
T
founder a8407cf16b fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью
недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с
таким именем не регистрировалось: при переименовании в `credentialStr` правка
не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на
несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при
разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery
превращал панику в HTTP 500 на каждый POST /api/auth/login.

Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники
apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его
валидатору отдельно — незарегистрированное правило паникует так же, как в бою,
но на сборке. Барьер проверен возвратом исходного тега.

Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал
панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz
отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход
bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду
HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба
идёт в любом режиме операции и от актуальности пароля не зависит.

Рядом лежали три расхождения того же класса, найденные при разборе.

Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по
умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая
установка проходила целиком, создавая учётную запись, под которой невозможно
войти. Про одно имя существовало три расходящихся умолчания. Оба значения
теперь проверяются при разборе окружения — той стороной, которая их порождает:
отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход
на готовом сервере — переустановкой.

Панель была строже сервера. Форма входа ограничивала пароль 32 символами при
серверном пределе в 64, а форма смены пароля назначала до 64: пароль,
назначенный штатной операцией, после этого не вводился. Набор символов на
пароле отвергал значение, которое сервер принял бы, — сервер его не
ограничивает нигде. Контракт учётных данных объявлен один раз в
service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним
тестами, читающими Go-исходник.

Класс символов логина был записан диапазоном по опечатке: неэкранированный
дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным
набором это совпадало только потому, что обе стороны несли одну опечатку. Набор
записан явно и НЕ сужен — он уже действует на установленных серверах.

Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus
рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а
форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся
иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх,
проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина
начиналась после иконки, у пароля обрывалась перед «глазом». Индикация
перенесена на элемент, который оператор и видит полем; чужая тень гасится
селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, —
конкретностью, а не !important. Остальные формы панели проверены: собственная
рамка на el-form-item есть только на форме входа.

Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому —
UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login
сразу после успешной проверки пароля; отказ записи вход не отменяет, но
попадает в журнал. Обработчик входа переехал из controller/peer.go в
controller/auth.go: стек в journal указывал на управление пирами.

Требование теперь называется, а не сообщается фактом нарушения. «Неверный
формат логина» и «Некорректное значение» не давали оператору способа узнать,
что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не
показан. Фразы форм и серверная причина credential_format перечисляют границы
и набор.

Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления —
по той же причине, что и гейт детектора гонок. Каждое из его утверждений
проверено мутационной пробой на реальный отказ; две первые редакции оказались
вакуумными и переписаны.

Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов
панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки
целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это
релизный гейт сборщика.

Прогон задокументирован в
docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
2026-09-04 02:32:50 +05:00

16 KiB
Raw Blame History

B, C. Установка на target и runtime

Часть набора проверок HY2XS. Карта всех частей — docs/testing/README.md.

B. Target install tests

На чистом Debian 13 проверяем

  1. пакет запускается без ручной сборки на сервере
  2. Hysteria2 скачивается с official upstream
  3. bundled HY2XS admin раскладывается локально из пакета
  4. создаются нужные каталоги
  5. создаются systemd unit-файлы
  6. создаются hy2xs.env и post-install.env с правами 0600 root:root
  7. baseline firewall применяется корректно через staged mode
  8. SSH остаётся доступным
  9. reconfigure --dry-run выводит план изменений
  10. reconfigure --apply применяет изменения и проходит smoke

C. Runtime tests

  1. hysteria-server active
  2. hy2xs-admin active
  3. Hysteria слушает только IPv4 (0.0.0.0:<udp_port>)
  4. HY2XS admin слушает ожидаемый HY2XS_UI_BIND_HOST:<ui_port>
  5. тестовый совместимый клиент подключается
  6. идёт реальный трафик
  7. лимит 50/50 Mbps соблюдается при согласованной клиентской конфигурации
  8. reboot не ломает baseline
  9. Hysteria2 управляется systemd unit, а не внутренним updater'ом admin panel
  10. нет IPv6 listen ([::]) для Hysteria/HY2XS admin
  11. trafficStats.secret не равен JWT_SECRET
  12. bootstrap admin secret существует и имеет 0600
  13. trafficStats API: корректный secret принимает запрос, неверный secret отклоняется
  14. TLS mode в config.yaml соответствует runtime env (acme|file|self_signed_dev)
  15. при HY2XS_TLS_MODE=acme в config.yaml выставлен acme.type из HY2XS_ACME_TYPE
  16. direct hysteria2:// node URL в API/QR формируется по HY2XS_PUBLIC_HOST + HY2XS_PUBLIC_PORT; subscription delivery endpoint отключён в baseline и не входит в acceptance
  17. nft -c -f /etc/nftables.conf проходит после apply
  18. пароль admin и con_pass не перезаписываются при рестарте hy2xs-admin
  19. остановка/рестарт UI не останавливает hysteria-server
  20. traffic accounting/kick обращаются к Traffic Stats API напрямую и не используют systemd status как гейт принятия решений; ключа HYSTERIA2_ENABLE в базе больше нет
  21. /etc/hysteria/config.yaml имеет 0640 hysteria:hy2xs-admin
  22. hy2xs-admin может читать /etc/hysteria/config.yaml, но не может писать
  23. смена расписания сброса трафика применяется без перезапуска hy2xs-admin, и число джоб планировщика не растёт
  24. невалидное cron-выражение отклоняется API, а значение в базе не меняется
  25. systemctl restart hy2xs-admin завершает сервис штатно: планировщик остановлен до закрытия SQLite, в журнале нет database is closed
  26. govulncheck ./... на графе релиза не находит вызываемых уязвимостей
  27. живая сессия, которой в базе больше ничего не соответствует (пир удалён либо его auth_id заменён импортом, а разрыв в тот момент не удался), завершается очередным циклом учёта — не позднее 30 секунд
  28. превышение maxDevices живыми сессиями устраняется тем же циклом: после неудавшегося разрыва при снижении лимита повтор формы даёт успех без /kick, и единственный механизм схождения здесь — cron
  29. смена секрета пира меняет его auth_id: клиент со старым секретом теряет доступ не позднее 30 секунд даже в том случае, когда /kick прошёл успешно, а соединение зарегистрировалось после него
  30. trafficStats.listen слушает 127.0.0.1. Админка принимает ровно три формы — 127.0.0.1, 0.0.0.0 и пустой хост (тот же wildcard), — а любой другой адрес, включая прочие адреса loopback вроде 127.0.0.5, отвергает с явным сообщением: слушатель на конкретном адресе соединения на 127.0.0.1 не принимает. Страница конфигурации показывает три состояния: канон профиля, достижим но опубликован шире необходимого (wildcard), недостижим
  31. hysteria-server.service запущен с HYSTERIA_DISABLE_UPDATE_CHECK=1: внешних запросов проверки версии при старте нет
  32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
  33. страница журнала Hysteria показывает разобранные level/time/msg и структурный контекст, а не сырой JSON
  34. страница конфигурации показывает фактические значения /etc/hysteria/config.yaml, перечисляет секции вне production-профиля и не содержит паролей и токенов
  35. оператор входит в панель: POST /api/auth/login с bootstrap-учётными данными из /etc/hy2xs/bootstrap-admin.secret отвечает code: 20000 и непустым accessToken. Заведомо неверные учётные данные дают HTTP 200 с конвертом отказа, а не 500
  36. пароль предельной длины (64 символа), назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными
  37. last_login_at администратора обновляется после успешного входа и не меняется после неудачной попытки

C0. Панель обязана впускать, а не слушать порт

Проверки 1-4 отвечают на вопрос «поднялось ли», и ни одна из них не отвечает на вопрос «работает ли». RC2 показал разницу: юнит активен, 127.0.0.1:8080 в LISTEN, /healthz отвечает ok: true — и POST /api/auth/login отдаёт HTTP 500 на каждый запрос, потому что валидатор паникует на теге несуществующего правила. Установка при этом завершилась INSTALL EXIT CODE: 0.

Поэтому вход в панель проверяется настоящим запросом, а не косвенными признаками, и эта проверка встроена в smoke оркестратора — то есть релиз с недоступной панелью физически не может завершиться успешной установкой. Ручной эквивалент:

# Пароль в переменную, чтобы он не попал ни в историю shell, ни в вывод.
read -r -s BOOTSTRAP_PASS < <(sudo grep '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret | cut -d= -f2-)
BOOTSTRAP_USER="$(sudo grep '^ADMIN_USER=' /etc/hy2xs/bootstrap-admin.secret | cut -d= -f2-)"

curl -sS --max-time 5 -X POST \
  -H 'Content-Type: application/json' \
  --data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$BOOTSTRAP_PASS" '{username:$u,pass:$p}')" \
  http://127.0.0.1:8080/api/auth/login | jq '.code, (.data.accessToken | length)'

unset BOOTSTRAP_PASS

Ожидается 20000 и ненулевая длина токена. Сам токен не печатается: это действующая сессия администратора.

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

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

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

effective Hysteria version == версия из metadata пакета

obfs:
  type == HY2XS_HYSTERIA_OBFS_TYPE
  ровно один подблок, соответствующий type
  password непустой
  для gecko: minPacketSize == 512, maxPacketSize == 1200

bandwidth:
  up/down == runtime env
  disableLossCompensation == false

congestion:
  type == bbr
  bbrProfile == standard

quic:
  disableStatelessReset == false
  окна, maxIncomingStreams, disablePathMTUDiscovery == baseline
  maxIdleTimeout == 30s

trafficStats:
  listen == runtime env
  secret непустой

auth:
  type == http
  url == http://127.0.0.1:<UI_PORT>/internal/hysteria/auth?access_token=<machine token>
  insecure == (tlsMode == self_signed_dev)

TLS:
  acme-режим не содержит секции tls
  acme: type/email/ca/dir/listenHost/первый домен == профиль
  file-режим не содержит секции acme

верхний уровень:
  нет секций вне production-профиля

maxIdleTimeout присутствовал в профиле, но не проверялся: конфиг с уехавшим idle timeout проходил семантическую проверку. Точно так же auth.http.url раньше сверялся только на наличие подстроки access_token=, из-за чего уехавший порт или путь остались бы незамеченными — а это единственный канал допуска пиров.

Сообщение об ошибке для auth.http.url намеренно не печатает сам токен: текст уходит в логи и в diagnostics-бандл. Это закреплено отдельным тестом.

C2. End-to-end с реальным клиентом

tools/test/e2e-hysteria.sh, отдельно для Gecko и Salamander:

  1. сервер принимает сгенерированный конфиг и стартует;
  2. TLS handshake;
  3. handshake с обфускацией;
  4. HTTP auth HY2XS: разрешённый пир принят;
  5. HTTP auth HY2XS: неразрешённый пир отклонён;
  6. клиент подключается именно по ссылке, которую выдаёт production-код;
  7. TCP forwarding;
  8. UDP forwarding;
  9. trafficStats с валидным secret;
  10. trafficStats с невалидным secret отклоняется;
  11. per-peer accounting содержит аутентифицированного пира;
  12. перезапуск сервера;
  13. быстрое переподключение клиента (поведение stateless reset).

Пункт 6 — тот самый, который ловит класс ошибок, неизбежный при наивном включении Gecko: сервер работает, ссылка формально валидна, а клиент по ней не подключается.

Одна реализация URI, а не две

Ссылка берётся из production-генератора через apps/tools/share-uri, который вызывает ту же service.BuildHysteria2ShareURI, что и панель.

Раньше внутри e2e жила вторая реализация URI на bash. Go-юнит-тесты проверяли production-генератор, e2e проверял свою функцию — и дрейф любой из них оставлял обе группы тестов зелёными.

Единственное расхождение с пользовательской ссылкой — insecure=1: e2e работает на самоподписанном сертификате. Это расхождение ограничено с двух сторон:

  • e2e отдельно печатает и проверяет production-вариант ссылки (insecure=0, корректные obfs и sni);
  • Go-тест TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam доказывает, что кроме этого параметра ссылки совпадают побайтово;
  • Go-тест TestBuildHysteria2Url_ProductionPathNeverDisablesVerification фиксирует, что production-путь никогда не передаёт insecure=1.

Для запуска e2e нужен Go (GO_BIN).

C3. Share URI (unit)

apps/service/hysteria2_api_test.go:

  • Gecko URI содержит obfs=gecko и obfs-password;
  • Salamander URI содержит obfs=salamander и obfs-password;
  • конфиг без обфускации даёт ссылку без obfs;
  • неизвестный тип обфускации в ссылку не попадает;
  • обфускация без пароля в ссылку не попадает;
  • SNI: ACME-домен → HY2XS_DOMAINHY2XS_PUBLIC_HOST, IP не используется;
  • спецсимволы в credentials и obfs-пароле переживают round-trip: +, пробел, #, @, /, ?, &, =, %, кириллица;
  • литеральный + кодируется как %2B и не схлопывается с пробелом (регрессия на upstream-баг 2.9.3).

C4. Экспорт конфига (unit)

apps/service/hysteria2_export_test.go:

  • неизвестные upstream-секции переживают экспорт целиком, включая вложенные карты и списки;
  • операционные поля остаются читаемыми;
  • вырезаются: obfs-пароль, trafficStats.secret, access_token, auth.userpass, учётные данные ACME DNS, пароли outbound;
  • вырезается неизвестное поле с секретным именем;
  • URL под произвольным именем ключа (endpoint:) теряет учётные данные и access_token, но сохраняет адрес; то же для URL внутри списка;
  • не-URL скаляры (50 mbps, 0.0.0.0:443, 10.0.0.1:1080, 30s, числа) проходят санитайзер без изменений;
  • пути к файлам (tls.key, ech.keyPath, clientCA) остаются видимыми.

Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами: граница определяется значением, а не именем ключа.