# B, C. Установка на target и runtime Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](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:`) 4. HY2XS admin слушает ожидаемый `HY2XS_UI_BIND_HOST:` 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 с конвертом отказа: `code: 50000`, причина `invalid_credentials` и отсутствие `accessToken` 36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой 37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным 37a. `HY2XS_ADMIN_INITIAL_PASSWORD` со значением вне домена systemd (`U+FDD0`, `U+FFFF`, невалидный UTF-8) **роняет `preflight-install`** — то есть отказ приходит до первой мутации хоста, а не после установки пакетов; сервер остаётся нетронутым 38. `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 оркестратора — то есть релиз с недоступной панелью физически не может завершиться успешной установкой. Ручной эквивалент: ```bash # Значение читается ПО ФОРМАТУ, а не `cut -d= -f2-`. # # Набор символов пароля не ограничен, а пробелы по краям являются его частью, # поэтому такое значение записано в файле в двойных кавычках с экранированием # `\` и `"`. `cut` отдал бы кавычки как часть пароля, а `read -r` вдобавок # срезал бы пробелы — и проверка объявила бы рабочую установку сломанной. # # `source` и `eval` здесь НЕ годятся: внутри двойных кавычек shell выполняет # подстановку команд, то есть пароль вида `$(...)` был бы исполнен. У самого # systemd подстановок в EnvironmentFile нет, и снимать кавычки надо без shell. read_bootstrap_field() { sudo sed -n "s/^$1=//p" /etc/hy2xs/bootstrap-admin.secret | head -n1 \ | sed -e 's/^"//' -e 's/"$//' -e 's/\\\(["\\]\)/\1/g' } BOOTSTRAP_USER="$(read_bootstrap_field ADMIN_USER)" BOOTSTRAP_PASS="$(read_bootstrap_field ADMIN_INITIAL_PASSWORD)" # Положительная проба: конверт успеха и выданный токен. 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)' # Отрицательная проба: пароль СЛУЧАЙНЫЙ, а проверяется конверт отказа целиком. # HTTP 200 сам по себе ничего не доказывает — админка отвечает 200 и на успех. curl -sS --max-time 5 -X POST \ -H 'Content-Type: application/json' \ --data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$(head -c 18 /dev/urandom | base64)" '{username:$u,pass:$p}')" \ http://127.0.0.1:8080/api/auth/login \ | jq '{code, reason: (.errors[0].code), token: (.data.accessToken // null)}' # Ожидается: {"code":50000,"reason":"invalid_credentials","token":null} unset BOOTSTRAP_PASS ``` Ожидается `20000` и ненулевая длина токена. Сам токен не печатается: это действующая сессия администратора. ## C1. Семантический smoke конфига Недостаточно `grep` по YAML: он не отличит нужное поле от такой же строки в другой секции и не заметит оставшийся рядом лишний подблок. Smoke разбирает `/etc/hysteria/config.yaml` и сверяет с production-профилем: ```text 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:/internal/hysteria/auth?access_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_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-санитайзеры описывают один контракт и покрыты зеркальными тестами: граница определяется значением, а не именем ключа.