fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.
Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».
Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.
Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.
Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.
Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.
Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.
docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
# 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 ориентируются на 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 ./...` на графе релиза не находит вызываемых уязвимостей
|
||||
|
||||
## 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-санитайзеры описывают один контракт и покрыты зеркальными тестами:
|
||||
граница определяется значением, а не именем ключа.
|
||||
|
||||
Reference in New Issue
Block a user