Files
HY2XS_flamy/docs/acceptance/2026-09-01-v1.0.0-rc1-ux-findings.md
founder 162759c599 fix(admin): достроить вторые половины отзыва доступа, лимита и журнала
Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.

Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.

Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.

Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.

Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
2026-09-01 17:17:17 +05:00

295 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 1.0.0-rc1 — дефекты приёмки и их закрытие
Относится к прогону
[2026-09-01, `v1.0.0-rc1`](2026-09-01-v1.0.0-rc1-host-acceptance.md).
Ни один из перечисленных дефектов не является P0 safety blocker и не
дискредитирует пройденную server acceptance. Все они заметно ухудшают работу
оператора и закрыты до финального `v1.0.0`.
Раздел «Найдено сверх отчёта» описывает дефекты того же класса, обнаруженные
при разборе корневых причин: искали причину одного отказа — нашли механизм,
порождавший несколько.
## Сводка
| ID | Дефект | Приоритет | Статус |
| --- | --- | --- | --- |
| UX-01 | Некорректный цвет SVG-иконок | P1 | закрыт |
| UX-02 | Необязательный секрет пира фактически обязателен | P1 | закрыт |
| UX-03 | Сообщение `Invalid` неинформативно | P1 | закрыт |
| UX-04 | Плейсхолдеры слишком персонализированы | P2 | закрыт |
| UX-05 | Нет атрибуции Flamy в боковом меню | P1 | закрыт |
| EX-01 | Фильтр списка пиров ломается после очистки | P1 | закрыт |
| EX-02 | Правила имени пира противоречили друг другу | P1 | закрыт |
| EX-03 | Набор символов имени пира допускал `/ : ; . ,` | P1 | закрыт |
| EX-04 | Истечение сессии не обрабатывалось | P1 | закрыт |
| EX-05 | `id` требовался и в пути, и в теле запроса | P2 | закрыт |
| EX-06 | Обработчик транспортных ошибок падал сам | P2 | закрыт |
---
## UX-01 — некорректный цвет SVG-иконок
**Наблюдалось:** иконки логина и бокового меню отображались почти чёрными и не
соответствовали теме.
**Корневая причина.** Контракт `currentColor` в панели УЖЕ существовал —
`fill: currentcolor` объявлен и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`.
Он не действовал, потому что восемь из семнадцати ассетов несли литеральный
атрибут `fill="#000000"` прямо на `<path>`, а атрибут представления перебивает
унаследованное CSS-свойство. Под это попали ВСЕ семь иконок бокового меню
(`report`, `users`, `hysteria`, `setting`, `error`, `log-system`,
`log-hysteria`) на фоне `--menuBg: #181818`, а также `user` на форме входа.
Соседняя `password` литерального цвета не несёт и рисовалась белой — отсюда и
ощущение, что иконки не соответствуют друг другу.
Ни одна существующая проверка этого не видела: гейт приёмки проверял у ассетов
только наличие системы координат.
**Как закрыто.**
1. Литеральный цвет убран из монохромных ассетов: они несут `fill="currentColor"`.
2. Многоцветные ассеты (`download`, `upload`) объявлены явным списком
`MULTICOLOR_ICONS` и под проверку цвета не попадают — их палитра является
частью ассета.
3. Преобразование файла в `<symbol>` и контракт ассета вынесены в чистый модуль
`SvgIcon/symbol.ts`: без Vite и DOM, поэтому проверяются тестом и гейтом, а
не только глазами на живой странице.
4. У `SvgIcon` убран проп `color` и атрибут `fill` на `<use>` — он приглашал
чинить цвет точечно в обход общего контракта.
5. Цвета в рантайме НЕ переписываются: источник истины — файл. Молчаливая
нормализация скрывала бы ровно тот дефект, который контракт обязан делать
видимым.
**Чем закреплено:** `tools/test/frontend-sprite.test.ts` (контракт всех
ассетов, обе ветки нормализации, наличие обеих половин контракта — ассета и
CSS, запрет CSS-фильтров и селекторов по имени иконки) и соответствующие гейты
приёмки в `tools/build/lib/acceptance.sh`.
**Что проверяется вручную** (машина этого не докажет): фактический цвет на
светлой и тёмной теме, в состояниях hover и active, в свёрнутом меню.
---
## UX-02 — необязательный секрет фактически обязателен
**Наблюдалось:** подпись под полем обещает «оставьте пустым — сгенерируем
автоматически», пустое поле блокирует создание пира и выдаёт `Invalid`.
**Корневая причина.** Не отсутствие автогенерации: `service.CreatePeer` умел
генерировать секрет и делал это. Запрос до неё не доходил.
В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле
объявлено указателем и указатель не nil. Помощник `hasValue` (`baked_in.go`):
```go
if fl.(*validate).fldIsPointer && getValue(field) != nil {
return true
}
```
Для `*string`, указывающего на пустую строку, это возвращает «значение есть».
Панель отправляет `secret: ""`, правило `min=6` применяется к пустой строке и
отказывает.
**Как закрыто.** Не тегом на одном поле, а механизмом: между разбором тела и
проверкой правил добавлен шаг нормализации DTO (`dto.Normalizable`). Он
приводит «поле отсутствует», «пустая строка» и «одни пробелы» к одному
состоянию для тех полей, где отсутствие значения законно.
Граница проходит по каждому полю ОТДЕЛЬНО и это существенно: у `remark` пустая
строка означает «убрать пометку», у `disabled` ноль означает «включён», у
`quotaBytes` ноль — нулевую квоту. Общее правило «пусто → не задано» молча
сломало бы все три.
Генерация названа явным шагом сервисного слоя — `service.GeneratePeerSecret` на
базе `util.RandomString` (`crypto/rand` с отбрасыванием смещённых байтов). Тот
же вызов используется импортом: пир, созданный формой, и пир, импортированный
без секрета, теперь неотличимы.
**Чем закреплено:** матрица «отсутствует / пусто / пробелы / перевод строки →
генерируется», границы `5 → отказ, 6 → приём, 128 → приём, 129 → отказ`,
неповторяемость сгенерированных секретов и — главное — проверка того, что
сгенерированный секрет НЕМЕДЛЕННО аутентифицирует пира через
`service.Hysteria2Auth`. То, что секрет записан, ничего не значит, пока по нему
не проходит доступ.
---
## UX-03 — сообщение `Invalid` неинформативно
**Корневая причина.** `validateField` схлопывал любую ошибку любого поля в
`constant.InvalidError = "invalid"`, а `vo.Fail` определял HTTP-семантику
СРАВНЕНИЕМ текста сообщения с тремя известными литералами — тот же антипаттерн,
который запрещён панели, только на сервере.
**Как закрыто.**
* Ответ об ошибке несёт `errors: [{code, field, message, params}]`.
* Отказ разбора тела (`body_invalid`) отделён от нарушения правила.
* Коды правил различают границы числа и границы длины строки
(`min` / `min_length`): оператору это разные фразы.
* Доменные отказы получили коды: `peer_name_taken`, `peer_name_reserved`,
`peer_bootstrap_identity_locked`, `invalid_credentials`.
* `vo` больше не выводит код из текста — код передаётся аргументом.
* Панель выбирает локализованную фразу ПО КОДУ и подставляет причины под
соответствующие поля формы; текст сервера остаётся ответом для клиента без UI
и запасным вариантом для неизвестного кода.
* Числа правил приходят в `params`, поэтому второй копии границ в панели нет.
Отдельно: отказ входа кодируется как `invalid_credentials`, но НЕ уточняется —
«такого администратора нет» и «пароль не тот» остаются неразличимы снаружи,
иначе форма входа становится способом проверять существование имён. Отказ базы
при этом остаётся системной ошибкой: выдавать «неверный логин или пароль» при
недоступной SQLite значит отправить оператора искать несуществующую опечатку.
---
## UX-04 — плейсхолдеры слишком персонализированы
Заменено на нейтральный компактный baseline:
| Поле | Было | Стало |
| --- | --- | --- |
| Имя | `например, ivan-laptop` | `client-01` |
| Комментарий | `например, Ноутбук Ивана, отдел продаж` | `ноутбук` |
Префикс «например,» убран: плейсхолдер и так является примером. Подсказки под
полями остались подробными; подсказка имени теперь называет фактические границы
(6-32 символа).
---
## UX-05 — атрибуция Flamy в боковом меню
Внизу бокового меню добавлена подпись «Разработано во **Flamy**», где `Flamy`
ссылка на `https://flamy.studio` фирменным цветом, с
`target="_blank"` и `rel="noopener noreferrer"`.
Адрес объявлен ОДИН раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API, ни
из таблицы `config`, ни из настроек панели. Оператор HY2XS не должен иметь
возможности переназначить, куда ведёт подпись разработчика.
Вёрстка: высота области прокрутки меню вычитает `$sidebarFooterHeight`, поэтому
пункты меню не могут наехать на подпись даже при длинном списке — им физически
некуда. В свёрнутом меню (54 px) остаётся только имя-ссылка; на узком экране
меню уходит в off-canvas на полную ширину.
**Чем закреплено:** `tools/test/frontend-contract.test.ts` — единственность
адреса в исходниках панели, отсутствие его в операторских поверхностях, наличие
футера в меню, учёт его высоты, атрибуты безопасности внешней ссылки. Плюс
гейты приёмки.
---
# Найдено сверх отчёта
## EX-01 — фильтр списка пиров ломался после очистки
`el-input` с крестиком очистки ставит пустую строку, axios сериализует её как
`?name=`, и та же ловушка `omitempty` на указателе (см. UX-02) отказывала
поиску пиров с `invalid`. Список пиров ломался в один клик по крестику.
Закрыто тем же механизмом нормализации; закреплено регрессионным тестом.
## EX-02 — правила имени пира противоречили друг другу
На поле стояли `min=1,max=32` И `validateStr`, требовавший 6-32 символа. Имя из
трёх символов проходило одно правило и отказывалось на другом, а оператор видел
`invalid` рядом с подсказкой «короткий идентификатор пира».
Длина перенесена внутрь одного правила `peerName`. Действующая граница — 6-32,
то есть та, которая уже была задокументирована и закреплена тестами импорта.
## EX-03 — набор символов имени пира допускал `/ : ; . ,`
Слой контроллеров нёс собственную копию правила:
```text
^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
```
с комментарием «тот же набор символов, что и у импорта». Набор был другим:
дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и впускает
`, - . / 0-9 : ; < =`. Через панель проходило имя `peer/name`, которое импорт
того же пира отклонял, — при том что имя пира уезжает во fragment клиентской
ссылки и в автогенерируемый секрет.
Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими
дверями в таблицу пиров.
Набор символов ЛОГИНА администратора при этом сознательно НЕ сужен: он
записан явно, но повторяет прежнее фактическое множество. Имя администратора
приходит из `HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение
правила означало бы, что установка с логином вроде `admin.ops` перестаёт
пускать оператора в панель. Это закреплено отдельным тестом, чтобы попытка
«навести порядок» роняла сборку, а не вход на живом сервере.
## EX-04 — истечение сессии не обрабатывалось
Ветка «сессия истекла, войдите заново» была недостижима в двух местах сразу.
Сервер отвечает HTTP 200 на любой отказ, поэтому обработчик ошибок axios для
отказов API не вызывался вовсе — а ветка сессии жила именно там. Условие в ней
проверяло `code === "A0230"` и поле `msg`, которых в этом API никогда не было.
Ключ локализации `common.sessionExpired` существовал и был мёртвым.
Вдобавок истёкший токен уезжал с кодом системной ошибки: `vo` не узнавал
`token expired` среди трёх известных строк.
Закрыто: `ParseToken` возвращает объявленные значения ошибок вместо свежих
строк, middleware различает истечение и недействительность через `errors.Is`,
ответ несёт код `session_expired`, панель показывает диалог и возвращает на
форму входа. Диалог показывается один раз, даже когда истёкший токен уронил
несколько параллельных запросов страницы.
Побочно: сброс сессии больше не зовёт `localStorage.clear()`, который заодно
стирал выбранный оператором язык панели.
## EX-05 — `id` требовался и в пути, и в теле
`PeerUpdateDto` встраивал `IdDto` с правилом `required`, поэтому тело запроса
обязано было повторять идентификатор из адреса. Панель его повторяла, поэтому
расхождение не проявлялось; любой другой клиент, сделавший `PATCH /peers/7` без
`"id": 7` в теле, получал отказ — при том что значение из тела всё равно
затирается значением из пути.
Заодно убрана недостижимая запасная ветка `resolveID`, читавшая идентификатор
из тела: она вызывала разбор тела, которое обработчик читает следом второй раз,
а gin его не буферизует. То есть запасной путь не сработал бы ровно тогда,
когда понадобился бы.
## EX-06 — обработчик транспортных ошибок падал сам
Обработчик читал `error.response.data`, не проверив `error.response`. При
обрыве соединения или таймауте он падал с `TypeError` и подменял настоящую
причину отказом внутри себя. Теперь сетевой отказ отличается от отказа сервера
и сообщается отдельной фразой.
---
## Разбор кода после этих исправлений
Проверка внесённых здесь исправлений по дереву на коммите `c0a43ae9` нашла
дефекты, которых хостовой прогон `rc1` не показывал, — включая два отдельных
пути, по которым молча снималось ограничение устройств, и отключение пира, не
разрывавшее его активную сессию. Они перечислены в
[2026-09-01-v1.0.0-rc2-preflight-findings.md](2026-09-01-v1.0.0-rc2-preflight-findings.md)
и закрыты до сборки `rc2`.
## Что осталось сделать до финального v1.0.0
1. пересобрать `rc2` и повторить build/security acceptance;
2. установить `rc2` на тестовый хост;
3. проверить визуально: цвет иконок в обеих темах, hover/active, свёрнутое меню,
подпись Flamy на узком экране;
4. проверить создание пира с пустым секретом и работоспособность его share URI
на живом сервере;
5. выполнить reconfigure/fault matrix D1-D1h на `rc2`;
6. выполнить reboot acceptance;
7. отдельно проверить Salamander fallback;
8. после готовности HY2XS Desktop — внешний Gecko E2E;
9. удалить или пересоздать тестовый пир, чья URI была выведена за пределы
admin UI во время прогона `rc1`.