a8407cf16b
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.
215 lines
16 KiB
Markdown
215 lines
16 KiB
Markdown
# 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:<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 оркестратора — то есть релиз с
|
||
недоступной панелью физически не может завершиться успешной установкой.
|
||
Ручной эквивалент:
|
||
|
||
```bash
|
||
# Пароль в переменную, чтобы он не попал ни в историю 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-профилем:
|
||
|
||
```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:<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_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-санитайзеры описывают один контракт и покрыты зеркальными тестами:
|
||
граница определяется значением, а не именем ключа.
|
||
|