From c0a43ae9154d881c54b75b8c104b0f57b8ee5e08 Mon Sep 17 00:00:00 2001 From: Crimson Date: Tue, 1 Sep 2026 07:27:15 +0500 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D0=B1=D0=B5=D1=89=D0=B0=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8,=20=D0=BA=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83=D0=BA?= =?UTF-8?q?=D1=82=20=D0=BD=D0=B5=20=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8F=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Девятый проход, по итогам приёмки 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" на , а атрибут представления перебивает унаследованное 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 после разнесения по каталогам совпадал бы ровно с одним файлом. --- CHANGELOG.md | 157 +- README.md | 38 +- apps/controller/config_test.go | 2 + apps/controller/errors.go | 24 + apps/controller/peer.go | 58 +- apps/controller/peer_validation_test.go | 505 ++++++ apps/controller/validator.go | 200 ++- apps/controller/validator_test.go | 78 + apps/frontend/src/api/peer/index.ts | 4 + apps/frontend/src/assets/icons/error.svg | 2 +- apps/frontend/src/assets/icons/hysteria.svg | 2 +- .../src/assets/icons/log-hysteria.svg | 2 +- apps/frontend/src/assets/icons/log-system.svg | 2 +- apps/frontend/src/assets/icons/report.svg | 2 +- apps/frontend/src/assets/icons/setting.svg | 2 +- apps/frontend/src/assets/icons/user.svg | 2 +- apps/frontend/src/assets/icons/users.svg | 2 +- .../frontend/src/components/SvgIcon/index.vue | 27 +- .../frontend/src/components/SvgIcon/sprite.ts | 69 +- .../frontend/src/components/SvgIcon/symbol.ts | 206 +++ apps/frontend/src/constants/branding.ts | 17 + apps/frontend/src/lang/package/en.ts | 66 +- apps/frontend/src/lang/package/ru.ts | 74 +- .../src/layout/components/Sidebar/Footer.vue | 71 + .../src/layout/components/Sidebar/index.vue | 2 + apps/frontend/src/styles/sidebar.scss | 12 +- apps/frontend/src/styles/variables.scss | 7 + apps/frontend/src/utils/api-error.ts | 111 ++ apps/frontend/src/utils/api-message.ts | 73 + apps/frontend/src/utils/request.ts | 131 +- apps/frontend/src/views/peer/list/index.vue | 198 ++- apps/middleware/admin.go | 6 +- apps/middleware/jwt.go | 53 +- apps/middleware/session_test.go | 90 + apps/model/constant/error.go | 57 + apps/model/dto/dto.go | 10 + apps/model/dto/log.go | 5 + apps/model/dto/normalize.go | 81 + apps/model/dto/normalize_test.go | 122 ++ apps/model/dto/peer.go | 68 +- apps/model/vo/result.go | 94 +- apps/service/admin_user.go | 18 +- apps/service/jwt.go | 24 +- apps/service/jwt_test.go | 11 +- apps/service/peer.go | 42 +- apps/service/peer_errors.go | 64 + apps/service/peer_import.go | 39 +- apps/service/peer_secret.go | 49 + docs/11-testing-and-acceptance.md | 1518 ----------------- docs/README.md | 54 +- .../2026-09-01-v1.0.0-rc1-host-acceptance.md | 674 ++++++++ .../2026-09-01-v1.0.0-rc1-ux-findings.md | 285 ++++ docs/acceptance/README.md | 33 + docs/{ => admin}/04-admin-panel.md | 7 +- docs/admin/15-ui-contracts.md | 142 ++ .../01-architecture-baseline.md | 0 .../{ => architecture}/03-server-hysteria2.md | 0 .../05-client-and-access-scope.md | 0 .../06-speed-limits-and-congestion.md | 0 .../10-access-layer-out-of-scope.md | 0 .../{ => build}/02-build-layer-and-package.md | 21 +- .../12-operations-and-troubleshooting.md | 0 .../{ => operations}/13-production-runbook.md | 0 docs/{ => operations}/14-legacy-cleanup.md | 2 +- docs/{ => runtime}/07-systemd-and-firewall.md | 0 docs/{ => runtime}/08-orchestrator-spec.md | 2 +- docs/{ => runtime}/09-post-install-env.md | 0 docs/testing/11-1-how-to-run.md | 49 + docs/testing/11-2-builder-layer.md | 714 ++++++++ docs/testing/11-3-target-and-runtime.md | 174 ++ docs/testing/11-4-fault-injection.md | 386 +++++ docs/testing/11-5-negative-and-matrix.md | 230 +++ docs/testing/README.md | 24 + orchestrator/src/commands/reconfigure.ts | 2 +- orchestrator/src/config/env.ts | 2 +- orchestrator/src/lib/installState.ts | 2 +- orchestrator/src/steps/cleanHost.ts | 2 +- orchestrator/test/clean-host.test.ts | 2 +- orchestrator/test/install-state.test.ts | 2 +- package/docs/README.md | 2 +- tools/build/build.sh | 6 + tools/build/lib/acceptance.sh | 162 +- tools/build/lib/package.sh | 23 + tools/build/lib/versions.sh | 2 +- tools/test/frontend-contract.test.ts | 243 +++ tools/test/frontend-sprite.test.ts | 312 ++++ 86 files changed, 6237 insertions(+), 1819 deletions(-) create mode 100644 apps/controller/errors.go create mode 100644 apps/controller/peer_validation_test.go create mode 100644 apps/controller/validator_test.go create mode 100644 apps/frontend/src/components/SvgIcon/symbol.ts create mode 100644 apps/frontend/src/constants/branding.ts create mode 100644 apps/frontend/src/layout/components/Sidebar/Footer.vue create mode 100644 apps/frontend/src/utils/api-error.ts create mode 100644 apps/frontend/src/utils/api-message.ts create mode 100644 apps/middleware/session_test.go create mode 100644 apps/model/dto/normalize.go create mode 100644 apps/model/dto/normalize_test.go create mode 100644 apps/service/peer_errors.go delete mode 100644 docs/11-testing-and-acceptance.md create mode 100644 docs/acceptance/2026-09-01-v1.0.0-rc1-host-acceptance.md create mode 100644 docs/acceptance/2026-09-01-v1.0.0-rc1-ux-findings.md create mode 100644 docs/acceptance/README.md rename docs/{ => admin}/04-admin-panel.md (99%) create mode 100644 docs/admin/15-ui-contracts.md rename docs/{ => architecture}/01-architecture-baseline.md (100%) rename docs/{ => architecture}/03-server-hysteria2.md (100%) rename docs/{ => architecture}/05-client-and-access-scope.md (100%) rename docs/{ => architecture}/06-speed-limits-and-congestion.md (100%) rename docs/{ => architecture}/10-access-layer-out-of-scope.md (100%) rename docs/{ => build}/02-build-layer-and-package.md (95%) rename docs/{ => operations}/12-operations-and-troubleshooting.md (100%) rename docs/{ => operations}/13-production-runbook.md (100%) rename docs/{ => operations}/14-legacy-cleanup.md (99%) rename docs/{ => runtime}/07-systemd-and-firewall.md (100%) rename docs/{ => runtime}/08-orchestrator-spec.md (99%) rename docs/{ => runtime}/09-post-install-env.md (100%) create mode 100644 docs/testing/11-1-how-to-run.md create mode 100644 docs/testing/11-2-builder-layer.md create mode 100644 docs/testing/11-3-target-and-runtime.md create mode 100644 docs/testing/11-4-fault-injection.md create mode 100644 docs/testing/11-5-negative-and-matrix.md create mode 100644 docs/testing/README.md create mode 100644 tools/test/frontend-contract.test.ts create mode 100644 tools/test/frontend-sprite.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 611ffea..a658e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,155 @@ Hardening-проход перед релизом `1.0.0`. Основная те результат на достаточно большом входе, опаснее отсутствующей: отсутствующая ничего не обещает. +Девятый проход — работа оператора в панели, по итогам приёмки `v1.0.0-rc1` на +живом Debian 13. Общая тема прохода: обещания интерфейса, которые продукт не +выполнял, хотя умел. Подпись под полем предлагала оставить секрет пустым, и +сервер действительно умел его сгенерировать — до этой генерации не доходило +управление. Контракт `currentColor` был объявлен в двух местах — и не +действовал, потому что цвет был вписан в сами ассеты. Ветка «сессия истекла, +войдите заново» существовала — и была недостижима сразу по двум причинам. + +### Исправлено — панель оператора + +- **Необязательный секрет пира был фактически обязателен.** Панель обещала + «оставьте пустым — сгенерируем автоматически» и отправляла `secret: ""`. + В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле + объявлено указателем и указатель не nil: помощник `hasValue` считает + указатель на пустую строку «значением». Правило `min=6` применялось к пустой + строке и отказывало, а генерация в `CreatePeer` оставалась недостижимой. + + Ловушка закрыта механизмом, а не тегом на одном поле: между разбором тела и + проверкой правил появился шаг нормализации DTO (`dto.Normalizable`). Граница + проходит по каждому полю отдельно — у `remark` пустая строка означает + «убрать пометку», у `disabled` ноль означает «включён», и общее правило + «пусто → не задано» молча сломало бы оба. + + Той же ловушкой ломался фильтр списка пиров: `el-input` с крестиком очистки + ставит пустую строку, axios сериализует её как `?name=`, и поиск отказывал в + один клик по крестику. + +- **Генерация секрета названа явным шагом сервисного слоя.** + `service.GeneratePeerSecret` на базе `util.RandomString` (`crypto/rand` с + отбрасыванием смещённых байтов) используется и формой, и импортом: пир, + созданный панелью, и пир, импортированный без секрета, теперь неотличимы. + +- **Любая ошибка любого поля превращалась в слово `invalid`.** Слой `vo` при + этом определял код ответа СРАВНЕНИЕМ текста сообщения с тремя литералами — + тот же антипаттерн, который запрещён панели, только на сервере. Ответ об + ошибке теперь несёт `errors: [{code, field, message, params}]`; панель + выбирает локализованную фразу по коду и подставляет причины под поля формы. + Границы числа и границы длины строки различаются кодом, хотя тег валидатора + у них один: оператору это разные фразы. + +- **Истечение сессии не обрабатывалось.** Сервер отвечает HTTP 200 на любой + отказ, поэтому обработчик ошибок axios для отказов API не вызывался вовсе — + а ветка сессии жила именно там; её условие проверяло `code === "A0230"` и + поле `msg`, которых в этом API никогда не было. Вдобавок истёкший токен уезжал + с кодом системной ошибки. Теперь `ParseToken` возвращает объявленные значения + ошибок вместо свежих строк, middleware различает истечение и + недействительность через `errors.Is`, а панель показывает диалог и + возвращает на форму входа — один раз, даже когда истёкший токен уронил + несколько параллельных запросов страницы. + +- **Обработчик транспортных ошибок падал сам.** Он читал `error.response.data`, + не проверив `error.response`, и при обрыве соединения подменял настоящую + причину `TypeError` внутри себя. + +- **Сброс сессии больше не зовёт `localStorage.clear()`**, который заодно стирал + выбранный оператором язык панели. + +- **`id` требовался и в пути, и в теле запроса.** `PeerUpdateDto` встраивал + `IdDto` с правилом `required`, хотя значение из тела всё равно затирается + значением из пути. Заодно убрана недостижимая запасная ветка `resolveID`, + читавшая идентификатор из тела: она вызывала разбор тела, которое обработчик + читает следом второй раз, а gin его не буферизует. + +### Исправлено — отрисовка иконок + +- **Контракт `currentColor` был объявлен и не действовал.** `fill: currentcolor` + стоял и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`, но восемь из + семнадцати ассетов несли литеральный `fill="#000000"` прямо на ``, а + атрибут представления перебивает унаследованное CSS-свойство. Под это + попадали все семь иконок бокового меню на фоне `#181818`. + + Литеральный цвет убран из ассетов; многоцветные объявлены явным списком; + преобразование в `` и контракт ассета вынесены в чистый модуль + `SvgIcon/symbol.ts`, который можно выполнить вне Vite и DOM — и, значит, + проверить. Цвета в рантайме НЕ переписываются: молчаливая нормализация + скрывала бы ровно тот дефект, который контракт обязан делать видимым. + +- **У `SvgIcon` убран проп цвета** и атрибут `fill` на ``: он приглашал + чинить отрисовку точечно в обход общего контракта. + +### Исправлено — правила имени пира + +- **Два правила на одном поле противоречили друг другу.** Стояли + `min=1,max=32` и `validateStr`, требовавший 6-32 символа: имя из трёх + символов проходило одно правило и отказывалось на другом. Длина перенесена + внутрь одного правила. + +- **Набор символов в слое контроллеров впускал `, - . / : ; <`.** Копия правила + несла неэкранированный дефис, из-за чего `+-=` образовывал ДИАПАЗОН; её + комментарий при этом утверждал, что набор тот же, что у импорта. Через панель + проходило имя `peer/name`, которое импорт того же пира отклонял, — при том что + имя уезжает во fragment клиентской ссылки и в автогенерируемый секрет. + Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими + дверями в таблицу пиров. + + Набор символов ЛОГИНА администратора сознательно не сужен: он записан явно, + но повторяет прежнее фактическое множество. Имя администратора приходит из + `HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение правила + означало бы, что установка с логином вроде `admin.ops` перестаёт пускать + оператора в панель. Закреплено отдельным тестом, чтобы попытка «навести + порядок» роняла сборку, а не вход на живом сервере. + +### Добавлено — атрибуция и контрактные тесты панели + +- **Подпись «Разработано во Flamy»** внизу бокового меню, ссылкой фирменным + цветом. Адрес объявлен один раз в `apps/frontend/src/constants/branding.ts` и + принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API, + ни из таблицы `config`. Высота области прокрутки меню вычитает высоту + подписи, поэтому пункты меню не могут на неё наехать. + +- **Контрактные тесты панели** (`tools/test/frontend-*.test.ts`) стали + обязательным шагом сборки наравне с тестами оркестратора и админки: контракт + спрайта иконок, совпадение наборов ключей `ru` и `en`, соответствие кодов + ошибок серверным константам, единственность адреса атрибуции. + + Их исполняет уже закреплённый в `versions.env` Bun, а не vitest: jsdom не + вычисляет `currentColor` и визуальной корректности всё равно не доказал бы, + зато vitest привёл бы в граф `pnpm audit` — а его порог считается по всему + lock-файлу frontend — сотню транзитивных зависимостей ради нулевой + дополнительной гарантии. + +### Изменено — документация + +- **`docs/` разложена по слоям** вместо плоской кучи из четырнадцати файлов: + `architecture/`, `build/`, `runtime/`, `admin/`, `operations/`, `testing/`, + `acceptance/`. Двузначный префикс сохранён как стабильный идентификатор + документа — под ним на него ссылаются CHANGELOG, релизные гейты и сообщения + оркестратора. + +- **`11-testing-and-acceptance.md` (117 КБ, 57 разделов) разбит на пять частей** + по слоям, на которых выполняются проверки. + +- **Добавлен `docs/acceptance/`** — отчёты о фактических прогонах приёмки, + отдельно от описания самих проверок. Документ проверок переживает релизы; + отчёт о прогоне относится к одному артефакту и одному хосту и после + публикации не редактируется. Первый отчёт — build/host acceptance + `v1.0.0-rc1` на Debian 13 с перечнем найденных дефектов и их закрытия. + +- **Добавлен `docs/admin/15-ui-contracts.md`** — контракты панели, которые не + проверяются ни типами, ни сборкой bundle. + +- **Зафиксировано требование к памяти build-хоста:** `govulncheck` строит граф + достижимости по всему модулю вместе со stdlib, и на машине с ~1.9 GiB RAM без + swap он был убит OOM killer. + +- **Обход документации в приёмке стал рекурсивным.** Плоский шаблон + `docs/*.md` после разнесения по каталогам совпадал бы ровно с одним файлом, + то есть проверка отчитывалась бы зелёным, не заглянув почти никуда. + ### Исправлено — гейты сборки - **Пайплайн в поиск с флагом `-q` под `pipefail` инвертирует смысл проверки.** @@ -1069,7 +1218,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те фрагмент nftables, systemd-юниты, база админки и наследие `0.x`. Пути установки и данных берутся из конфигурации, а не захардкожены. -- **`tools/legacy/purge-v0.sh`** и [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) — +- **`tools/legacy/purge-v0.sh`** и [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) — явная очистка сервера от предыдущего поколения. По умолчанию скрипт показывает план и ничего не делает; выполнение требует `--apply --yes-i-know`. Из установщика он не вызывается никогда: это вернуло @@ -1143,7 +1292,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те - **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема — `apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в - [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) — там это имя чужого + [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) — там это имя чужого артефакта, который очистка должна найти. - **Индикатор загрузки и legacy-цвета переведены на брендовый токен.** @@ -1210,7 +1359,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те существует. Номера оставшихся миграций сохранены: перенумерация заставила бы их примениться повторно. - В `docs/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они + В `docs/operations/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они обозначают реальные объекты, которые нужно удалить с сервера. Из остальных v1-доков этот словарь убран. @@ -1326,7 +1475,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те 1. Выпишите с работающего сервера список пиров и их секреты. 2. Очистите сервер: `tools/legacy/purge-v0.sh` или ручная процедура из - [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md). + [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md). 3. Разверните `1.0.0` на чистом Debian 13 из release-пакета. 4. Заведите пиров заново и раздайте новые клиентские ссылки. diff --git a/README.md b/README.md index 5b1074f..f09c323 100644 --- a/README.md +++ b/README.md @@ -515,7 +515,7 @@ HY2XS_UI_PUBLIC_ACCESS=false Если PHASE 0 не прошла, установщик завершается с ошибкой и **сервер остаётся в том же состоянии, в котором был**. HY2XS v1 не устанавливается поверх предыдущего поколения и не мигрирует его состояние: очистка старой установки — -отдельная явная операция, см. [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md). +отдельная явная операция, см. [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md). ### 10. Получите bootstrap‑пароль админки @@ -818,7 +818,7 @@ HY2XS v1 не поддерживает установку поверх и не Что делать: 1. сохраните нужные данные (база пиров, конфиг) — см. - [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md); + [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md); 2. посмотрите план очистки: `sudo ./purge-v0.sh`; 3. выполните очистку: `sudo ./purge-v0.sh --apply --yes-i-know`; 4. повторите установку. @@ -997,22 +997,25 @@ export GITHUB_TOKEN= 1. проверяет контракт `versions.env` (`verify_versions_contract`); 2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`); -3. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт; -4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander; -5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта; -6. прогоняет `go vet` и `go test` для HY2XS admin; -7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу); -8. формирует архив и прогоняет acceptance‑проверки. +3. прогоняет контрактные тесты панели (спрайт иконок, словари локализации, коды ошибок, атрибуция); +4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт; +5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander; +6. собирает orchestrator, frontend и backend, проставляя версию админки из контракта; +7. прогоняет `go vet` и `go test` для HY2XS admin; +8. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу); +9. формирует архив и прогоняет acceptance‑проверки. -Любой сбой на шагах 1–7 останавливает сборку до создания пакета. +Любой сбой на шагах 1–8 останавливает сборку до создания пакета. -Тесты и типы (шаги 2 и 6) — такой же обязательный гейт, как проверка +Тесты и типы (шаги 2, 3 и 7) — такой же обязательный гейт, как проверка зависимостей: переменной, которая их отключает, не существует. Готовый пакет объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это утверждение опирается на фактический прогон, а не на намерение. Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`, -`go vet ./...` и `go test ./...` запускаются напрямую и tarball не создают. +`go vet ./...`, `go test ./...` и +`bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts` +запускаются напрямую и tarball не создают. Переменные, управляющие выбором версии Hysteria: @@ -1087,9 +1090,16 @@ tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \ ├── package/ # skeleton будущего install package ├── tools/build/ # production builder и packaging pipeline ├── tools/dev/ # doctor: сверка среды разработки с versions.env -├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria +├── tools/test/ # e2e с реальным клиентом Hysteria и контракты панели ├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения -├── docs/ # спецификации baseline, тестов и эксплуатации +├── docs/ # документация, разложенная по слоям +│ ├── architecture/ # baseline-модель и рамки +│ ├── build/ # builder layer и состав пакета +│ ├── runtime/ # оркестратор, systemd, post-install +│ ├── admin/ # HY2XS admin и контракты панели +│ ├── operations/ # runbook, разбор отказов, очистка 0.x +│ ├── testing/ # набор проверок по слоям +│ └── acceptance/ # отчёты о фактических прогонах приёмки ├── versions.env # контракт продукта, платформы и toolchain ├── CHANGELOG.md ├── README.md @@ -1098,6 +1108,8 @@ tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \ Каталог `dist/` создаётся builder’ом и не должен храниться в git. +Точка входа в документацию — [docs/README.md](docs/README.md). + ## Для кого этот проект HY2XS рассчитан на операторов, которым нужен воспроизводимый способ поставить Hysteria2‑сервер с локальной панелью управления, не собирая проект на production‑сервере и не открывая admin UI наружу. diff --git a/apps/controller/config_test.go b/apps/controller/config_test.go index d221305..316cf84 100644 --- a/apps/controller/config_test.go +++ b/apps/controller/config_test.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "hy2xs-admin/dao" "hy2xs-admin/model/constant" + "hy2xs-admin/model/vo" "hy2xs-admin/service" ) @@ -29,6 +30,7 @@ type apiResult struct { Code int `json:"code"` Type string `json:"type"` Message string `json:"message"` + Errors []vo.FieldError `json:"errors"` Data json.RawMessage `json:"data"` } diff --git a/apps/controller/errors.go b/apps/controller/errors.go new file mode 100644 index 0000000..43e6b99 --- /dev/null +++ b/apps/controller/errors.go @@ -0,0 +1,24 @@ +package controller + +import ( + "errors" + + "github.com/gin-gonic/gin" + "hy2xs-admin/model/vo" + "hy2xs-admin/service" +) + +// failService переводит отказ сервисного слоя в ответ панели. +// +// Доменный отказ несёт код и, если он относится к полю формы, имя этого поля +// (см. service.PeerError). Всё остальное остаётся отказом уровня операции с +// человеческим сообщением — панель покажет его как есть, но разбирать текст ей +// при этом не придётся ни в одном известном случае. +func failService(err error, c *gin.Context) { + var peerErr *service.PeerError + if errors.As(err, &peerErr) { + vo.FailField(peerErr.Code, peerErr.Field, peerErr.Message, c) + return + } + vo.Fail(err.Error(), c) +} diff --git a/apps/controller/peer.go b/apps/controller/peer.go index 4dd05a6..e0cee2a 100644 --- a/apps/controller/peer.go +++ b/apps/controller/peer.go @@ -3,6 +3,7 @@ package controller import ( "bytes" "encoding/json" + "errors" "fmt" "io" "strconv" @@ -18,18 +19,31 @@ import ( "hy2xs-admin/service" ) +// resolveID читает идентификатор пира ИЗ ПУТИ и только оттуда. +// +// Запасной ветки «если в пути нет — разобрать тело» здесь больше нет. Все +// маршруты, ведущие сюда, объявлены с `:id` (см. router/peer.go), то есть +// ветка была недостижима. Хуже недостижимости было бы её срабатывание: она +// вызывала validateField, который читает тело запроса, а обработчик следом +// читает то же тело второй раз — gin его не буферизует, и второй разбор +// получил бы пустой поток. То есть запасной путь не работал бы ровно тогда, +// когда понадобился бы. func resolveID(c *gin.Context) (int64, error) { - if raw := strings.TrimSpace(c.Param("id")); raw != "" { - parsed, err := strconv.ParseInt(raw, 10, 64) - if err == nil && parsed > 0 { - return parsed, nil - } + raw := strings.TrimSpace(c.Param("id")) + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil || parsed <= 0 { + vo.FailValidation( + "идентификатор пира в адресе некорректен", + []vo.FieldError{{ + Code: constant.ErrCodeBodyInvalid, + Field: "id", + Message: fmt.Sprintf("ожидался положительный числовой идентификатор, получено %q", raw), + }}, + c, + ) + return 0, errors.New(constant.ErrCodeBodyInvalid) } - idDto, err := validateField(c, dto.IdDto{}) - if err != nil { - return 0, err - } - return *idDto.Id, nil + return parsed, nil } func Login(c *gin.Context) { @@ -39,6 +53,14 @@ func Login(c *gin.Context) { } token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass) if err != nil { + // Неверные учётные данные получают код, чтобы панель показала + // оператору внятную фразу на его языке. Отказ базы остаётся системной + // ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite + // значит отправить оператора искать несуществующую опечатку. + if errors.Is(err, service.ErrInvalidCredentials) { + vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c) + return + } vo.Fail(err.Error(), c) return } @@ -65,7 +87,7 @@ func SavePeer(c *gin.Context) { } peerVo, err := service.CreatePeer(peerSaveDto) if err != nil { - vo.Fail(err.Error(), c) + failService(err, c) return } vo.Success(peerVo, c) @@ -100,12 +122,12 @@ func UpdatePeer(c *gin.Context) { return } if taken { - vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c) + failService(service.PeerNameTakenError(*peerUpdateDto.Name), c) return } } if err = service.UpdatePeer(id, peerUpdateDto); err != nil { - vo.Fail(err.Error(), c) + failService(err, c) return } vo.Success(nil, c) @@ -158,7 +180,15 @@ func ImportPeer(c *gin.Context) { return } if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") { - vo.Fail(constant.InvalidError, c) + vo.FailValidation( + "импорт принимает только файлы .json", + []vo.FieldError{{ + Code: constant.ErrCodeImportFileExtension, + Field: "file", + Message: "импорт принимает только файлы .json", + }}, + c, + ) return } diff --git a/apps/controller/peer_validation_test.go b/apps/controller/peer_validation_test.go new file mode 100644 index 0000000..f27840a --- /dev/null +++ b/apps/controller/peer_validation_test.go @@ -0,0 +1,505 @@ +package controller + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "hy2xs-admin/dao" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/entity" + "hy2xs-admin/service" +) + +// Контракт формы пира: необязательный секрет и внятный отказ. +// +// Проверяется весь путь запроса — разбор тела, нормализация DTO, правила +// валидатора, сервис, база, — потому что дефект жил ровно на стыке этих +// слоёв и ни один из них по отдельности его не показывал: панель обещала +// автогенерацию, сервис умел её выполнить, а правило `omitempty,min=6` на +// поле-указателе отказывало раньше, чем управление доходило до сервиса. + +func newPeerControllerDB(t *testing.T) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") + if err := dao.InitSqliteDBAt(dbPath); err != nil { + t.Fatalf("не удалось открыть тестовую базу: %v", err) + } + if err := dao.RunMigrations(); err != nil { + t.Fatalf("не удалось применить миграции: %v", err) + } + t.Cleanup(func() { _ = dao.CloseSqliteDB() }) +} + +// peerPayload — тело создания пира со всеми обязательными полями. +// Тесты меняют в нём ровно то, что проверяют. +func peerPayload(name string) map[string]any { + return map[string]any{ + "name": name, + "quotaBytes": -1, + "expiresAt": 0, + "maxDevices": 3, + "disabled": 0, + "remark": "", + } +} + +func createPeer(t *testing.T, body map[string]any) apiResult { + t.Helper() + return postJSON(t, SavePeer, "/peers", body) +} + +// errorFor возвращает причину отказа по имени поля. +func errorFor(t *testing.T, result apiResult, field string) (string, bool) { + t.Helper() + for _, item := range result.Errors { + if item.Field == field { + return item.Code, true + } + } + return "", false +} + +func storedPeer(t *testing.T, name string) entity.Peer { + t.Helper() + peer, err := dao.GetPeer("name = ?", name) + if err != nil { + t.Fatalf("пир %q не найден в базе: %v", name, err) + } + return peer +} + +// Регрессия UX-02. Панель писала под полем «оставьте пустым — сгенерируем +// автоматически» и отправляла `secret: ""`. Правило `omitempty,min=6` на +// поле-указателе НЕ пропускалось (см. hasValue в baked_in.go валидатора), +// применялось к пустой строке и отказывало. Оператор видел «Invalid», а +// генерация в CreatePeer была недостижима. +func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) { + cases := map[string]func(map[string]any){ + "поле отсутствует": func(body map[string]any) {}, + "пустая строка": func(body map[string]any) { body["secret"] = "" }, + "только пробелы": func(body map[string]any) { body["secret"] = " " }, + "перевод строки": func(body map[string]any) { body["secret"] = "\n" }, + "табуляция и пробел": func(body map[string]any) { body["secret"] = "\t " }, + } + + for label, mutate := range cases { + t.Run(label, func(t *testing.T) { + newPeerControllerDB(t) + + body := peerPayload("client-01") + mutate(body) + + result := createPeer(t, body) + if result.Type != "ok" { + t.Fatalf("создание пира отклонено: code=%d message=%q errors=%+v", + result.Code, result.Message, result.Errors) + } + + peer := storedPeer(t, "client-01") + if peer.SecretEncrypted == nil || *peer.SecretEncrypted == "" { + t.Fatal("секрет не сохранён") + } + + secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted) + if err != nil { + t.Fatalf("сохранённый секрет не расшифровывается: %v", err) + } + if len(secret) < 6 { + t.Fatalf("сгенерирован слишком короткий секрет: %q", secret) + } + + // Сгенерированный секрет обязан РАБОТАТЬ немедленно: то, что он + // записан, ничего не значит, пока по нему не проходит проверка + // доступа. Это же связывает digest и шифртекст между собой. + id, authID, authErr := service.Hysteria2Auth(secret) + if authErr != nil { + t.Fatalf("пир не аутентифицируется своим секретом: %v", authErr) + } + if id != *peer.Id || authID != *peer.AuthId { + t.Fatalf("аутентифицировался другой пир: id=%d authId=%q", id, authID) + } + }) + } +} + +// Два одинаковых запроса не должны давать одинаковый секрет: генератор +// обязан быть случайным, а не производной от имени. +func TestGeneratedPeerSecretsDiffer(t *testing.T) { + newPeerControllerDB(t) + + secrets := make(map[string]struct{}, 5) + for _, name := range []string{"client-01", "client-02", "client-03", "client-04", "client-05"} { + if result := createPeer(t, peerPayload(name)); result.Type != "ok" { + t.Fatalf("создание %q отклонено: %+v", name, result) + } + peer := storedPeer(t, name) + secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted) + if err != nil { + t.Fatalf("секрет %q не расшифровывается: %v", name, err) + } + if _, seen := secrets[secret]; seen { + t.Fatalf("сгенерированный секрет повторился: %q", secret) + } + secrets[secret] = struct{}{} + } +} + +// Границы ручного секрета — ровно те, что обещает подсказка под полем. +func TestCreatePeerSecretLengthBoundaries(t *testing.T) { + cases := []struct { + label string + secret string + accepted bool + expectCode string + }{ + {"5 символов", strings.Repeat("a", 5), false, constant.ErrCodeMinLength}, + {"6 символов", strings.Repeat("a", 6), true, ""}, + {"128 символов", strings.Repeat("a", 128), true, ""}, + {"129 символов", strings.Repeat("a", 129), false, constant.ErrCodeMaxLength}, + } + + for _, tc := range cases { + t.Run(tc.label, func(t *testing.T) { + newPeerControllerDB(t) + + body := peerPayload("client-01") + body["secret"] = tc.secret + result := createPeer(t, body) + + if tc.accepted { + if result.Type != "ok" { + t.Fatalf("секрет длиной %d отклонён: %+v", len(tc.secret), result) + } + peer := storedPeer(t, "client-01") + stored, err := service.DecryptPeerSecret(*peer.SecretEncrypted) + if err != nil { + t.Fatalf("секрет не расшифровывается: %v", err) + } + if stored != tc.secret { + t.Fatalf("сохранён не тот секрет, который передали") + } + return + } + + if result.Type != "no" { + t.Fatalf("секрет длиной %d принят", len(tc.secret)) + } + code, ok := errorFor(t, result, "secret") + if !ok { + t.Fatalf("отказ не назвал поле secret: %+v", result.Errors) + } + if code != tc.expectCode { + t.Fatalf("код отказа %q, ожидался %q", code, tc.expectCode) + } + }) + } +} + +// Регрессия UX-03. Любая ошибка любого поля превращалась в одно слово +// `invalid`: панель не могла ни подсветить поле, ни объяснить причину, и +// вынуждена была бы разбирать текст, чтобы попытаться. +func TestCreatePeerNamesTheFieldAndTheRule(t *testing.T) { + cases := []struct { + label string + body func() map[string]any + field string + code string + }{ + { + label: "имя не передано", + body: func() map[string]any { + body := peerPayload("client-01") + delete(body, "name") + return body + }, + field: "name", + code: constant.ErrCodeRequired, + }, + { + label: "имя короче допустимого", + body: func() map[string]any { return peerPayload("pc1") }, + field: "name", + code: constant.ErrCodePeerName, + }, + { + label: "имя длиннее допустимого", + body: func() map[string]any { return peerPayload(strings.Repeat("a", 33)) }, + field: "name", + code: constant.ErrCodePeerName, + }, + { + label: "лимит устройств меньше единицы", + body: func() map[string]any { + body := peerPayload("client-01") + body["maxDevices"] = 0 + return body + }, + field: "maxDevices", + code: constant.ErrCodeMin, + }, + { + label: "disabled вне множества значений", + body: func() map[string]any { + body := peerPayload("client-01") + body["disabled"] = 7 + return body + }, + field: "disabled", + code: constant.ErrCodeOneOf, + }, + { + label: "квота меньше минимума", + body: func() map[string]any { + body := peerPayload("client-01") + body["quotaBytes"] = -2 + return body + }, + field: "quotaBytes", + code: constant.ErrCodeMin, + }, + { + label: "комментарий длиннее допустимого", + body: func() map[string]any { + body := peerPayload("client-01") + body["remark"] = strings.Repeat("я", 65) + return body + }, + field: "remark", + code: constant.ErrCodeMaxLength, + }, + } + + for _, tc := range cases { + t.Run(tc.label, func(t *testing.T) { + newPeerControllerDB(t) + + result := createPeer(t, tc.body()) + if result.Type != "no" { + t.Fatalf("некорректный ввод принят: %+v", result) + } + if result.Code != constant.CodeInvalidError { + t.Fatalf("код ответа %d, ожидался %d", result.Code, constant.CodeInvalidError) + } + code, ok := errorFor(t, result, tc.field) + if !ok { + t.Fatalf("отказ не назвал поле %q: %+v", tc.field, result.Errors) + } + if code != tc.code { + t.Fatalf("код отказа %q, ожидался %q", code, tc.code) + } + // Сообщение остаётся человекочитаемым для клиента без панели, но + // панель им не пользуется: у неё есть код. + if strings.TrimSpace(result.Message) == "" { + t.Fatal("отказ без человекочитаемого сообщения") + } + }) + } +} + +// Регрессия: слой контроллеров нёс собственную копию правила имени, в которой +// неэкранированный дефис превращал `+-=` в диапазон и впускал `, - . / : ; <`. +// Имя `peer/name` создавалось через панель и отклонялось импортом того же +// пира, хотя имя уезжает во fragment клиентской ссылки и в секрет. +func TestCreatePeerRejectsNamesOutsideTheCharset(t *testing.T) { + for _, name := range []string{ + "peer/name", + "peer:name", + "peer;name", + "peer,name", + "peer.name", + "peer { }); } +// Форма пира показывает причины отказа под своими полями, поэтому общий тост +// ей не нужен: он повторял бы то же самое вторым сигналом. export function savePeerApi(data: PeerSaveDto): AxiosPromise { return request({ url: "/peers", method: "post", data, + skipErrorToast: true, }); } @@ -44,6 +47,7 @@ export function updatePeerApi(data: PeerUpdateDto): AxiosPromise { url: `/peers/${data.id}`, method: "patch", data, + skipErrorToast: true, }); } diff --git a/apps/frontend/src/assets/icons/error.svg b/apps/frontend/src/assets/icons/error.svg index dcf2cea..6e70e39 100644 --- a/apps/frontend/src/assets/icons/error.svg +++ b/apps/frontend/src/assets/icons/error.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/hysteria.svg b/apps/frontend/src/assets/icons/hysteria.svg index c98c9f6..25d6270 100644 --- a/apps/frontend/src/assets/icons/hysteria.svg +++ b/apps/frontend/src/assets/icons/hysteria.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/log-hysteria.svg b/apps/frontend/src/assets/icons/log-hysteria.svg index dc4c6ef..500b627 100644 --- a/apps/frontend/src/assets/icons/log-hysteria.svg +++ b/apps/frontend/src/assets/icons/log-hysteria.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/log-system.svg b/apps/frontend/src/assets/icons/log-system.svg index 72331c1..8d2222d 100644 --- a/apps/frontend/src/assets/icons/log-system.svg +++ b/apps/frontend/src/assets/icons/log-system.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/report.svg b/apps/frontend/src/assets/icons/report.svg index bb90070..cde8a1e 100644 --- a/apps/frontend/src/assets/icons/report.svg +++ b/apps/frontend/src/assets/icons/report.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/setting.svg b/apps/frontend/src/assets/icons/setting.svg index 0f1962f..174296b 100644 --- a/apps/frontend/src/assets/icons/setting.svg +++ b/apps/frontend/src/assets/icons/setting.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/user.svg b/apps/frontend/src/assets/icons/user.svg index bcb2394..dc8f66f 100644 --- a/apps/frontend/src/assets/icons/user.svg +++ b/apps/frontend/src/assets/icons/user.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/users.svg b/apps/frontend/src/assets/icons/users.svg index ab86674..6064d0d 100644 --- a/apps/frontend/src/assets/icons/users.svg +++ b/apps/frontend/src/assets/icons/users.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/frontend/src/components/SvgIcon/index.vue b/apps/frontend/src/components/SvgIcon/index.vue index 744cf03..4974d8d 100644 --- a/apps/frontend/src/components/SvgIcon/index.vue +++ b/apps/frontend/src/components/SvgIcon/index.vue @@ -1,33 +1,42 @@ diff --git a/apps/frontend/src/layout/components/Sidebar/index.vue b/apps/frontend/src/layout/components/Sidebar/index.vue index 1d0d31e..36a8fd3 100644 --- a/apps/frontend/src/layout/components/Sidebar/index.vue +++ b/apps/frontend/src/layout/components/Sidebar/index.vue @@ -3,6 +3,7 @@ import { useRoute } from "vue-router"; import SidebarItem from "./SidebarItem.vue"; import Logo from "./Logo.vue"; +import Footer from "./Footer.vue"; import { usePermissionStore } from "@/store/modules/permission"; import { useAppStore } from "@/store/modules/app"; @@ -36,5 +37,6 @@ const route = useRoute(); /> +