# 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`; конфиг с не-loopback адресом админка отвергает с явным сообщением, а не молча ходит на loopback. Страница конфигурации различает три состояния адреса: канон профиля, достижимый но опубликованный шире необходимого (`0.0.0.0`, пустой хост, прочий loopback) и недостижимый 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-профиля и не содержит паролей и токенов ## 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-санитайзеры описывают один контракт и покрыты зеркальными тестами: граница определяется значением, а не именем ключа.