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.
This commit is contained in:
@@ -269,6 +269,15 @@ if fl.(*validate).fldIsPointer && getValue(field) != nil {
|
||||
|
||||
---
|
||||
|
||||
## Разбор кода после этих исправлений
|
||||
|
||||
Проверка внесённых здесь исправлений по дереву на коммите `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;
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
# Разбор кода перед сборкой `1.0.0-rc2`
|
||||
|
||||
Источник — не прогон на хосте, а разбор дерева на коммите `c0a43ae9` и сверка
|
||||
Hysteria-интеграции с официальной документацией Hysteria 2 и с API Element Plus.
|
||||
Поэтому файл отдельный: дефекты приёмки `rc1` перечислены в
|
||||
[2026-09-01-v1.0.0-rc1-ux-findings.md](2026-09-01-v1.0.0-rc1-ux-findings.md), и
|
||||
смешивать их с найденным при чтении кода значило бы приписать хостовому прогону
|
||||
то, чего он не показывал.
|
||||
|
||||
Проверить их на живом сервере ещё предстоит: на тестовом хосте по-прежнему
|
||||
стоит `rc1`. Список ручных проверок — в конце документа.
|
||||
|
||||
## Сводка
|
||||
|
||||
| ID | Дефект | Приоритет | Статус |
|
||||
| --- | --- | --- | --- |
|
||||
| UX-06 | Нет быстрого включения/отключения пира; отключение не рвёт сессию | P1 | закрыт |
|
||||
| UX-07 | Подсказка «Экспорт настроек» без ограничения ширины | P2 | закрыт |
|
||||
| UX-08 | Срок временной блокировки зашит в код и не сообщается | P2 | закрыт |
|
||||
| UX-09 | Отмена подтверждения считалась ошибкой | P2 | закрыт |
|
||||
| UX-10 | Подсказка имени пира описывала не действующее правило | P3 | закрыт |
|
||||
| UX-11 | Отказ показывался дважды, а транспортный — ни разу | P1 | закрыт |
|
||||
| LOG-01 | Журнал запросов пишет пустой `msg` | P1 | закрыт |
|
||||
| LOG-02 | Ширины колонок журнала не заданы | P1 UX | закрыт |
|
||||
| LOG-03 | Отказ выгрузки журнала не ловился и глушился | P1 | закрыт |
|
||||
| LOG-04 | Страница системных логов ломалась, пока нет файла журнала | P1 | закрыт |
|
||||
| LOG-05 | Одна битая строка журнала ломала весь ответ | P1 | закрыт |
|
||||
| AUTH-01 | `maxDevices` fail-open при отказе `/online` | P1 | закрыт |
|
||||
| AUTH-02 | `maxDevices` не проверялся, когда systemd отвечал «неактивна» | P1 | закрыт |
|
||||
| CORE-01 | Ошибка называет `TCP port` для UDP-эндпоинта | P3 | закрыт |
|
||||
| CORE-02 | `banned_until` писался дважды, отказ отчитывался как полный | P1 | закрыт |
|
||||
| TYPE-01 | Типы полей журнала в панели расходились с сервером | P3 | закрыт |
|
||||
|
||||
LOG-04, LOG-05, AUTH-02, CORE-02, UX-08…UX-11 и TYPE-01 в исходный разбор не
|
||||
входили и найдены при проверке его выводов по коду. UX-11 нашёлся позже
|
||||
остальных — при проверке уже внесённых исправлений.
|
||||
|
||||
---
|
||||
|
||||
## UX-06 — отключение пира не отзывало доступ
|
||||
|
||||
**Наблюдалось:** пункта включения/отключения нет в меню строки вовсе. Сменить
|
||||
состояние можно было только через форму изменения.
|
||||
|
||||
**Корневая причина глубже отсутствующего пункта.** Вернуть пункт и вызвать
|
||||
`PATCH /peers/:id` с `disabled: 1` было бы недостаточно: `UpdatePeer` записывал
|
||||
поле в SQLite и на этом заканчивался.
|
||||
|
||||
Запись `disabled=1` видит выборка в `Hysteria2Auth`, то есть она закрывает
|
||||
только БУДУЩИЕ обращения к HTTP-auth. Установленная QUIC-сессия живёт своей
|
||||
жизнью и сама по себе не разрывается: после «Отключить» пир продолжал
|
||||
пользоваться доступом сколько угодно долго, пока не переподключался по своей
|
||||
воле. Панель при этом показывала его отключённым.
|
||||
|
||||
Официальная документация Hysteria описывает эти половины как пару: Traffic
|
||||
Stats `/kick` завершает сессию, но клиент немедленно переподключается, поэтому
|
||||
одновременно требуется блокировка в auth backend. По отдельности не работает ни
|
||||
одна.
|
||||
|
||||
У продукта были обе половины, но разведённые по разным операциям:
|
||||
|
||||
```text
|
||||
disabled=1 -> блокирует последующий HTTP-auth
|
||||
POST /kick -> разрывает текущую сессию
|
||||
```
|
||||
|
||||
**Как закрыто.**
|
||||
|
||||
1. `service.DisconnectPeers(ids)` — только официальный `/kick`, без единой
|
||||
записи в базу. Прежний `Hysteria2Kick` вместе с разрывом проставлял
|
||||
`banned_until`, поэтому воспользоваться им для отключения было нельзя:
|
||||
операция записала бы заодно временную блокировку — другой механизм с другим
|
||||
сроком жизни и другим способом снятия.
|
||||
2. `UpdatePeer` при `disabled=1` выполняет обе половины: сначала долговременную
|
||||
запись, затем разрыв.
|
||||
3. Порядок обратному не подлежит. При обратном клиент успевает
|
||||
переподключиться в окне между `/kick` и записью и остаётся на связи с
|
||||
формально отключённым пиром. Порядок доказывается тестом, который снимает
|
||||
состояние базы В МОМЕНТ прихода `/kick`: после операции оба шага уже
|
||||
выполнены и проверять там нечего.
|
||||
4. Неудача разрыва НЕ откатывает `disabled`. Безопасная половина достигнута, и
|
||||
возвращать пиру полный доступ из-за отказа второго шага нельзя.
|
||||
5. Состояние службы по systemd на этом пути не спрашивается. `util.Exec`
|
||||
схлопывает «systemctl вернул 3» и «запустить systemctl не удалось» в одну
|
||||
ошибку, поэтому прежняя проверка `!Hysteria2IsRunning() -> отказ` отказывала
|
||||
бы операции при живой Hysteria. Обращение к `/kick` отвечает на нужный
|
||||
вопрос напрямую.
|
||||
6. Условие смотрит на ЗАПРОШЕННОЕ состояние, а не на переход из включённого:
|
||||
иначе после неудачного разрыва оператору пришлось бы включить пира, чтобы
|
||||
получить право отключить его снова.
|
||||
|
||||
**Частичный результат сообщается кодом, а не прозой.** Отдельный
|
||||
`peer_disconnect_failed`: без него оператор прочитал бы обычную ошибку как «не
|
||||
сработало, состояние прежнее» — вывод, прямо противоположный истине. Панель
|
||||
показывает его предупреждением и обновляет строку.
|
||||
|
||||
**Меню переведено на `command`.** `@click` на каждом `el-dropdown-item`
|
||||
заменён штатным контрактом `el-dropdown`: команда приходит в одно место, и
|
||||
добавить пункт, забыв его подключить, становится невозможно.
|
||||
|
||||
---
|
||||
|
||||
## AUTH-01 и AUTH-02 — ограничение устройств отключалось само
|
||||
|
||||
`Hysteria2Auth` после проверки пира спрашивал `/online`. При отказе:
|
||||
|
||||
```go
|
||||
onlineUsers, err := Hysteria2Online()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Warn(...)
|
||||
return *peer.Id, *peer.AuthId, nil
|
||||
}
|
||||
```
|
||||
|
||||
Недоступность внутреннего `127.0.0.1` превращала объявленный в панели «Лимит
|
||||
устройств: 3» в безлимит. Узнать об этом оператор мог только по строке `WARN` в
|
||||
журнале, которую никто не читает.
|
||||
|
||||
**Вторая половина дыры оказалась тише первой и в исходный разбор не входила.**
|
||||
`Hysteria2Online` начинался с ярлыка:
|
||||
|
||||
```go
|
||||
if !Hysteria2IsRunning() {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Пустая карта БЕЗ ОШИБКИ — это «онлайн никого», то есть лимит не проверяется, и
|
||||
в журнале не появляется ни строки. А `Hysteria2IsRunning` отвечает через
|
||||
`util.Exec`, который не отличает «служба неактивна» от «спросить не удалось».
|
||||
|
||||
**Как закрыто.** Пути разделены по назначению:
|
||||
|
||||
* `Hysteria2Online` — для ОТОБРАЖЕНИЯ (дашборд, признак online в списке).
|
||||
Терпимость сохранена: пустая картина — честный ответ на вопрос «кто сейчас на
|
||||
связи», когда служба остановлена.
|
||||
* `hysteria2Online` — фактический ответ Traffic Stats API, без ярлыков.
|
||||
Недоступность остаётся ошибкой. Этим путём идёт авторизация.
|
||||
|
||||
Направление отказа выбрано fail-closed осознанно: запрос авторизации приходит
|
||||
ОТ Hysteria, то есть в момент проверки Hysteria заведомо жива, а её Traffic
|
||||
Stats API слушает loopback внутри того же процесса. Его недоступность здесь —
|
||||
аномалия, а не штатное состояние.
|
||||
|
||||
**Следствие, о котором нужно знать оператору.** `maxDevices` имеет `min=1`,
|
||||
безлимита у него не бывает, поэтому недоступность Traffic Stats API отказывает
|
||||
в подключении всем пирам сразу — и это записывается в журнал уровнем `error`, а
|
||||
не `warn`. Обратный выбор означал бы молчаливое снятие лимита со всех пиров
|
||||
одновременно.
|
||||
|
||||
Заодно закрыты два соседних места на том же пути: секрет Traffic Stats API
|
||||
читался как `*config.Value` без проверки на nil (паника в обработчике
|
||||
machine-auth, то есть на пути каждого подключения пира), а повреждённый
|
||||
`maxDevices` трактовался бы как отсутствие границы.
|
||||
|
||||
---
|
||||
|
||||
## CORE-02 — временная блокировка отчитывалась отказом, будучи применённой
|
||||
|
||||
`KickPeer` записывал `banned_until`, затем звал `Hysteria2Kick`, который
|
||||
записывал `banned_until` ВТОРОЙ РАЗ тем же значением. Хуже дублирования был
|
||||
порядок отказа: `Hysteria2Kick` начинался с проверки состояния службы и
|
||||
возвращал ошибку, не сделав ничего, — но первая запись к этому моменту уже
|
||||
применилась. Операция отвечала чистым отказом, находясь в применённом
|
||||
состоянии.
|
||||
|
||||
Закрыто тем же примитивом, что и UX-06: долговременная запись, затем
|
||||
`DisconnectPeers`, затем — при неудаче разрыва — частичный результат отдельным
|
||||
кодом.
|
||||
|
||||
Механизмы остались независимыми: `banned_until` истекает сам, `disabled`
|
||||
снимается только руками; включение пира не сбрасывает временную блокировку, а
|
||||
её снятие не включает отключённого пира.
|
||||
|
||||
---
|
||||
|
||||
## LOG-01 — причина пустых системных логов
|
||||
|
||||
Это **не** рассогласование модели отображения с форматом файла. `LogSystemVo`
|
||||
полностью соответствует структуре записи.
|
||||
|
||||
Middleware собирал поля правильно, но затем вызывал:
|
||||
|
||||
```go
|
||||
entry.Error()
|
||||
entry.Warn()
|
||||
entry.Info()
|
||||
```
|
||||
|
||||
без аргумента сообщения, и logrus честно записывал `"msg":""` для каждого HTTP
|
||||
запроса. Пустой столбец на экране был точным отражением того, что записал
|
||||
backend.
|
||||
|
||||
**Как закрыто.** `middleware.RequestLogMessage` собирает строку ИЗ ТЕХ ЖЕ
|
||||
величин, что уже лежат в структурных полях:
|
||||
|
||||
```text
|
||||
GET /api/peers → 200 (7 ms)
|
||||
POST /internal/hysteria/auth → 200 (2 ms)
|
||||
```
|
||||
|
||||
Запись остаётся машиночитаемой; `msg` существует, чтобы человек мог прочитать
|
||||
её, не собирая строку из шести колонок.
|
||||
|
||||
Query-строка сюда не попадает. Это действующий контракт безопасности, а не
|
||||
небрежность: Hysteria обращается к машинному endpoint'у как
|
||||
`/internal/hysteria/auth?access_token=<machine token>` при каждом подключении
|
||||
пира, а журнал отдаётся оператору через `ExportLog` и уезжает в
|
||||
diagnostics-бандл. Тест проверяет обе половины сразу: `msg` непустой И не несёт
|
||||
ни токена, ни знака `?`.
|
||||
|
||||
---
|
||||
|
||||
## LOG-04 и LOG-05 — страница логов ломалась двумя разными способами
|
||||
|
||||
Ни один из них в исходный разбор не входил.
|
||||
|
||||
**LOG-04.** Ветка «файла журнала ещё нет» отвечала голым массивом:
|
||||
|
||||
```go
|
||||
vo.Success(logSystemVos, c)
|
||||
```
|
||||
|
||||
Панель читает `data.records`, поэтому получала `undefined` и передавала его в
|
||||
`:data` таблицы. То есть на свежепоставленном хосте — до первой записи в
|
||||
журнал — страница системных логов не работала вовсе. Это ровно тот сценарий,
|
||||
который проверяется на приёмке каждой чистой установки.
|
||||
|
||||
**LOG-05.** При неразбираемой строке выполнялось:
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
vo.Fail("Unable to unmarshal log data", c)
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
Ответ записывался в поток, цикл шёл дальше, а в конце безусловно выполнялся
|
||||
`vo.Success`. Клиент получал два JSON-документа подряд, то есть невалидный
|
||||
ответ. Достаточно было ОДНОЙ битой строки, чтобы страница перестала
|
||||
открываться целиком — а строка бьётся штатно: lumberjack ротирует файл, и
|
||||
обрыв последней записи на границе ротации — обычное событие.
|
||||
|
||||
**Как закрыто.** Форма ответа `{records, total}` на всех ветках; неразбираемая
|
||||
строка пропускается без записи ответа — остальные записи прочитаны и полезны.
|
||||
|
||||
---
|
||||
|
||||
## LOG-02 и LOG-03 — обе страницы журнала чинились дважды
|
||||
|
||||
Страницы системного журнала и журнала Hysteria были побайтово одинаковы, кроме
|
||||
вызываемого API, и несли одни и те же дефекты в двух экземплярах.
|
||||
|
||||
**LOG-02.** Ни `width`, ни `min-width` не заданы, поэтому Element Plus делил
|
||||
ширину практически поровну: уровень и время получали по трети строки, а
|
||||
сообщение — единственное содержимое журнала — тоже треть. Официальный API
|
||||
разделяет `width` (фиксирует) и `min-width` (участвует в распределении
|
||||
остатка).
|
||||
|
||||
**LOG-03.** Сетевой запрос стоял ПЕРЕД `try`:
|
||||
|
||||
```ts
|
||||
let response = await exportLogApi(...);
|
||||
try { ... } catch (e) { /* empty */ }
|
||||
```
|
||||
|
||||
Отказ самого запроса этим `catch` не ловился вовсе, а всё внутри глушилось
|
||||
молча. Оператор нажимал «Экспорт» и не получал ни файла, ни причины.
|
||||
|
||||
**Как закрыто.** Общий `components/LogViewer` — ширины, перенос сообщения,
|
||||
выгрузка и обработка её отказа объявлены один раз. Служебные колонки
|
||||
зафиксированы, колонка сообщения растягивается за счёт остатка и ПЕРЕНОСИТСЯ, а
|
||||
не обрезается многоточием: у Hysteria в `msg` приезжает диагностический JSON, и
|
||||
обрезанная строка не отвечает ни на один вопрос, ради которого страницу
|
||||
открыли.
|
||||
|
||||
**Сверх разбора: копий скачивания было четыре, а не три.** Четвёртую —
|
||||
выгрузку серверного конфига Hysteria — нашёл контрактный тест, потребовавший
|
||||
единственности `createObjectURL`. Она несла тот же дефект порядка и вдобавок
|
||||
падала на `dis.split(...)` при отсутствующем `Content-Disposition`, и это
|
||||
исключение тоже глушилось. Сборка ссылки на скачивание живёт теперь в
|
||||
`utils/download.ts` одна.
|
||||
|
||||
---
|
||||
|
||||
## UX-07 — ширина всплывающей подсказки
|
||||
|
||||
Подсказка объявлялась без ограничения, поэтому длинный перевод получал
|
||||
естественную ширину popper и растягивался почти на весь экран одной строкой.
|
||||
Element Plus предоставляет для этого штатный `popper-style`; ограничение
|
||||
поставлено им, а не глобальным CSS.
|
||||
|
||||
---
|
||||
|
||||
## UX-08, UX-09, UX-10 — найдено при разборе панели пиров
|
||||
|
||||
**UX-08.** `handleKick` зашивал `Date.now() + 60 * 60 * 1000` прямо в
|
||||
обработчик: пункт «Отключить» молча блокировал пира на час без подтверждения, а
|
||||
сколько продлится блокировка, не сообщалось ни до, ни после. Ключи локализации
|
||||
`peer.kickUtilTime` и `peer.releaseSuccess` при этом существовали и были
|
||||
мёртвыми. Теперь срок называется в подтверждении, а результат — сообщением;
|
||||
пункты переименованы так, чтобы «временно заблокировать» не путалось с
|
||||
«отключить пир».
|
||||
|
||||
**UX-09.** `ElMessageBox` отклоняет промис при нажатии «Отмена».
|
||||
`await ElMessageBox.confirm(...)` без разбора отказа оставлял необработанное
|
||||
отклонение промиса на каждую отмену — в четырёх местах страницы пиров и ещё
|
||||
одном в верхней панели (выход из системы). Отмена — это ОТВЕТ оператора;
|
||||
переведена в обычное `false` через `confirmAction`.
|
||||
|
||||
**UX-10.** Подсказка имени пира обещала «латиница, цифры и дефис», тогда как
|
||||
действующее правило (`service.IsValidPeerName`) принимает
|
||||
`a-z A-Z 0-9 !@#$%^&*()_+-=`. Подсказка осталась от правила, действовавшего до
|
||||
EX-03: она обещала более узкий набор, чем сервер принимает, и оператор не имел
|
||||
причин пробовать разрешённые символы. Тест теперь читает набор из серверной
|
||||
константы `PeerNameCharset`, поэтому разойтись снова они не могут.
|
||||
|
||||
---
|
||||
|
||||
## UX-11 — сообщение об отказе показывалось дважды или ни разу
|
||||
|
||||
Найдено при проверке собственных исправлений: два соседних дефекта в одном
|
||||
механизме, и оба вскрылись только когда действия строки пира начали сообщать
|
||||
свой исход сами.
|
||||
|
||||
**Дважды.** `deletePeerApi`, `resetPeerTrafficApi`, `kickPeerApi` и
|
||||
`releaseKickPeerApi` не объявляли `skipErrorToast`, поэтому после UX-06 отказ
|
||||
показывался и общим перехватчиком, и страницей. Для частичного результата
|
||||
отзыва доступа это давало два противоречащих сообщения об одном событии:
|
||||
предупреждение «состояние применено наполовину» и рядом ошибку.
|
||||
|
||||
**Ни разу.** Флаг `skipErrorToast` гасил не только отказ API, но и
|
||||
ТРАНСПОРТНЫЙ отказ — обрыв соединения, таймаут, HTTP-статус вне 2xx. При этом
|
||||
все страницы, объявлявшие флаг, в своих обработчиках писали
|
||||
`// транспортный отказ уже показан общим перехватчиком` и молчали. Утверждение
|
||||
было ложным: обрыв соединения при сохранении пира, его удалении или отзыве
|
||||
доступа не показывал оператору ничего — операция просто не происходила молча.
|
||||
Дефект существовал и до этого прохода, у `savePeerApi` и `updatePeerApi`.
|
||||
|
||||
**Как закрыто.** Флаг отнесён только к отказу API — тому, у которого есть
|
||||
конверт с `code` и `errors` и, значит, есть что разбирать. Транспортный отказ
|
||||
показывается всегда: у него конверта нет, страница о нём сказать ничего не
|
||||
может, и молчание о нём означает операцию без объяснений. Обе половины
|
||||
закреплены тестами.
|
||||
|
||||
---
|
||||
|
||||
## CORE-01 и TYPE-01 — мелкие расхождения
|
||||
|
||||
**CORE-01.** `resolvePublicEndpoint` при невалидном `HY2XS_PUBLIC_PORT` писал
|
||||
`must be a valid TCP port`, хотя публичный endpoint Hysteria — UDP/QUIC.
|
||||
Транспорт из формулировки убран, чтобы не закладывать в сообщение об ошибке
|
||||
заведомо ложную семантику.
|
||||
|
||||
**TYPE-01.** В панели `latencyTime` и `statusCode` объявлены строками, а сервер
|
||||
шлёт их как `int64`. Пока обе колонки не отображались, расхождение было
|
||||
безвредным; после LOG-01 и LOG-02 оно стало бы обычной ошибкой сравнения или
|
||||
форматирования.
|
||||
|
||||
---
|
||||
|
||||
## Чем закреплено
|
||||
|
||||
**Тесты Go** (`apps/service/peer_access_test.go`,
|
||||
`apps/controller/log_test.go`, `apps/middleware/log_test.go`):
|
||||
|
||||
* контракт `/kick` проверяется против НАСТОЯЩЕГО HTTP — метод, путь, заголовок
|
||||
`Authorization`, JSON-массив идентификаторов. Подменённый на уровне Go клиент
|
||||
доказал бы только то, что вызвана нужная функция, и молча пережил бы потерю
|
||||
заголовка;
|
||||
* порядок «запись → разрыв» — снимком состояния базы в момент прихода `/kick`;
|
||||
* неоткат `disabled` и `banned_until` при неудаче разрыва; повторяемость
|
||||
операции;
|
||||
* независимость `disabled` и `banned_until` друг от друга;
|
||||
* fail-closed при отказе `/online`, при недоступном порте и при systemd,
|
||||
отвечающем «служба неактивна»; граница `device == maxDevices`; повреждённый
|
||||
`maxDevices`;
|
||||
* сохранение терпимости пути отображения;
|
||||
* непустой `msg` вместе с отсутствием в нём токена и query-строки;
|
||||
* форма ответа страницы логов на всех ветках и пропуск битой строки.
|
||||
|
||||
**Контрактные тесты панели** (`tools/test/frontend-contract.test.ts`): общий
|
||||
`LogViewer` на обеих страницах, явные ширины колонок, запрос внутри `try`,
|
||||
единственность сборки скачивания, меню на `command` с пунктом
|
||||
`toggle-disabled`, ограничение ширины подсказки, единственный
|
||||
`ElMessageBox.confirm`, разбор частичного результата по коду, совпадение
|
||||
подсказки имени с серверной константой.
|
||||
|
||||
**Гейты приёмки** (`tools/build/lib/acceptance.sh`):
|
||||
`run_access_revocation_acceptance` и `run_observability_acceptance`.
|
||||
|
||||
Отдельно: проверка «единственный `ElMessageBox.confirm`» сначала поймала
|
||||
собственный комментарий, объясняющий, почему прямого вызова здесь больше нет, —
|
||||
ровно та ловушка, о которой предупреждает `code_without_comments` в
|
||||
`acceptance.sh`. Проверки панели теперь тоже отбрасывают комментарии.
|
||||
|
||||
---
|
||||
|
||||
## Что проверяется руками на `rc2`
|
||||
|
||||
Машина этого не докажет:
|
||||
|
||||
1. отключить пир с активным подключением и убедиться, что соединение
|
||||
действительно обрывается, а не только меняется плашка в списке;
|
||||
2. повторно подключиться отключённым пиром и получить отказ;
|
||||
3. включить пир обратно и убедиться, что подключение восстанавливается;
|
||||
4. остановить `hysteria-server`, отключить пир и прочитать предупреждение о
|
||||
частичном результате; убедиться, что строка показывает применённое
|
||||
состояние;
|
||||
5. проверить fail-closed `maxDevices`: сломать Traffic Stats API и убедиться,
|
||||
что подключение отклоняется, а в журнале появляется запись уровня `error`;
|
||||
6. открыть страницу системных логов на свежей установке ДО появления файла
|
||||
журнала;
|
||||
7. прочитать столбец `msg` на обеих страницах логов, проверить ширины колонок и
|
||||
перенос длинного JSON Hysteria;
|
||||
8. выгрузить оба журнала и конфиг Hysteria; отдельно проверить поведение при
|
||||
остановленной админке — отказ обязан быть виден;
|
||||
9. проверить ширину подсказки «Экспорт настроек» на узком экране;
|
||||
10. проверить, что отмена любого подтверждения не оставляет ошибок в консоли
|
||||
браузера.
|
||||
@@ -31,3 +31,13 @@
|
||||
| Прогон | Дефекты |
|
||||
| --- | --- |
|
||||
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
|
||||
|
||||
## Разборы кода между прогонами
|
||||
|
||||
Отдельно от отчётов о прогонах: дефекты, найденные чтением дерева и сверкой с
|
||||
официальной документацией, а не наблюдением на хосте. Провенанс у них другой, и
|
||||
приписывать их хостовому прогону нельзя — он их не показывал.
|
||||
|
||||
| Дата | Основание | Находки |
|
||||
| --- | --- | --- |
|
||||
| 2026-09-01 | коммит `c0a43ae9`, сверка с Hysteria 2 и Element Plus | [UX-06…UX-10, LOG-01…LOG-05, AUTH-01/02, CORE-01/02, TYPE-01](2026-09-01-v1.0.0-rc2-preflight-findings.md) |
|
||||
|
||||
@@ -113,9 +113,21 @@ Hysteria обращается к машинному endpoint'у как
|
||||
пира. Поэтому в журнале админки пишется **путь**, а не `RequestURI`:
|
||||
|
||||
```json
|
||||
{ "reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" }
|
||||
{ "msg": "POST /internal/hysteria/auth → 200 (2 ms)",
|
||||
"reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" }
|
||||
```
|
||||
|
||||
Поле `msg` собирается из тех же величин, что уже лежат в структурных полях, и
|
||||
не добавляет к ним ничего: запись остаётся машиночитаемой, а сообщение
|
||||
существует, чтобы человек мог прочитать строку журнала, не собирая её из шести
|
||||
колонок. Раньше `entry.Info()` вызывался без аргумента, и logrus записывал
|
||||
`"msg":""` для каждого запроса — страница системных логов показывала оператору
|
||||
пустой столбец, точно отражая содержимое файла.
|
||||
|
||||
Читаемость сообщения не является лазейкой для query-строки: в `msg` попадает
|
||||
только путь, и это закреплено тестом, который проверяет обе половины сразу —
|
||||
сообщение непустое И не несёт ни токена, ни знака `?`.
|
||||
|
||||
Пока логировался `RequestURI`, действующий machine token оседал открытым
|
||||
текстом в `/var/log/hy2xs/hy2xs-admin.log`. Этот файл отдаётся оператору через
|
||||
`ExportLog` и попадает в diagnostics-бандл, то есть секрет утекал наружу в
|
||||
@@ -525,6 +537,78 @@ upstream выберет для нового секрета. Список мар
|
||||
Конфигурация Hysteria остаётся доступной панели **на чтение и на выгрузку**:
|
||||
`GET /config/getHysteria2Config` и `POST /config/exportHysteria2Config`.
|
||||
|
||||
### Отзыв доступа к VPN состоит из двух половин
|
||||
|
||||
Панель не управляет жизненным циклом Hysteria, но доступом пиров управляет
|
||||
целиком — и здесь у неё есть ровно один механизм, требующий обеих половин
|
||||
официального контракта Hysteria.
|
||||
|
||||
```text
|
||||
disabled = 1 закрывает БУДУЩИЕ обращения к HTTP-auth
|
||||
POST /kick завершает УЖЕ УСТАНОВЛЕННУЮ сессию
|
||||
```
|
||||
|
||||
Ни одна половина не работает по отдельности. Запись `disabled=1` видит только
|
||||
выборка в `Hysteria2Auth`, то есть проверяется при следующем подключении;
|
||||
установленная QUIC-сессия живёт своей жизнью и сама не разрывается. Обратно:
|
||||
`/kick` завершает сессию, но клиент немедленно переподключается — поэтому
|
||||
официальная документация Hysteria и требует одновременной блокировки в auth
|
||||
backend.
|
||||
|
||||
**Порядок обязателен и обратному не подлежит:**
|
||||
|
||||
```text
|
||||
1. записать disabled = 1 (долговременное состояние)
|
||||
2. POST /kick по authId пира (разрыв)
|
||||
```
|
||||
|
||||
При обратном порядке клиент успевает переподключиться в окне между разрывом и
|
||||
записью и остаётся на связи с формально отключённым пиром.
|
||||
|
||||
**Неудача второго шага не откатывает первый.** Безопасная половина достигнута;
|
||||
возвращать пиру полный доступ из-за отказа разрыва нельзя. Операция отвечает
|
||||
частичным результатом с кодом `peer_disconnect_failed`, панель показывает его
|
||||
предупреждением и обновляет строку. Повторить операцию можно тем же действием:
|
||||
условие смотрит на запрошенное состояние, а не на переход из включённого.
|
||||
|
||||
**Отключение и временная блокировка — разные механизмы**, и смешивать их
|
||||
нельзя:
|
||||
|
||||
| | снимается | назначение |
|
||||
| --- | --- | --- |
|
||||
| `disabled` | только руками оператора | отзыв доступа |
|
||||
| `banned_until` | истекает сам | временная блокировка |
|
||||
|
||||
Поэтому `DisconnectPeers` не пишет в базу вовсе, включение пира не сбрасывает
|
||||
`banned_until`, а снятие блокировки не включает отключённого пира.
|
||||
|
||||
**Состояние службы по systemd в этом пути не участвует.** `util.Exec`
|
||||
схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не
|
||||
удалось» в одну ошибку, поэтому `Hysteria2IsRunning` не является основанием ни
|
||||
для отказа операции, ни для её пропуска. Ответ даёт само обращение к Traffic
|
||||
Stats API.
|
||||
|
||||
### Ограничение устройств проверяется fail-closed
|
||||
|
||||
`maxDevices` проверяется по `/online` Traffic Stats API, который возвращает
|
||||
число экземпляров клиента Hysteria — то есть именно «устройства», а не число
|
||||
proxy-потоков.
|
||||
|
||||
Недоступность этого API **отклоняет подключение** и пишет запись уровня
|
||||
`error`. Выбор направления осознанный: запрос авторизации приходит от самой
|
||||
Hysteria, значит она жива, а её Traffic Stats API слушает loopback внутри того
|
||||
же процесса — его недоступность является аномалией, а не штатным состоянием.
|
||||
Обратный выбор молча снимал бы объявленный в панели лимит со всех пиров сразу,
|
||||
и единственным следом этого была бы строка `warn` в журнале.
|
||||
|
||||
У `maxDevices` есть `min=1`, безлимита не бывает, поэтому такой отказ
|
||||
затрагивает всех пиров одновременно. Это ожидаемое поведение, а не деградация:
|
||||
доступность Traffic Stats API входит в install/doctor smoke.
|
||||
|
||||
Путь ОТОБРАЖЕНИЯ остаётся терпимым: дашборд и признак `online` в списке пиров
|
||||
показывают пустую картину, когда служба остановлена, — это честный ответ на
|
||||
вопрос «кто сейчас на связи».
|
||||
|
||||
### Что нельзя делать
|
||||
|
||||
- собирать admin-компонент на target server;
|
||||
@@ -533,6 +617,10 @@ upstream выберет для нового секрета. Список мар
|
||||
- раздувать оркестратор из-за особенностей панели;
|
||||
- использовать HY2XS admin как updater бинаря Hysteria2;
|
||||
- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API;
|
||||
- считать `disabled=1` завершённым отзывом доступа без `/kick`;
|
||||
- откатывать `disabled` из-за неудачи `/kick`;
|
||||
- писать `banned_until` из пути отключения пира;
|
||||
- пропускать проверку лимита устройств, когда Traffic Stats API не ответил;
|
||||
- экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
|
||||
- выгружать конфиг с секретами в открытом виде.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Контракты панели
|
||||
|
||||
Три свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
|
||||
Свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
|
||||
потому ломались молча. Каждое из них закреплено тестом
|
||||
(`tools/test/frontend-*.test.ts`) и гейтом приёмки.
|
||||
|
||||
@@ -134,7 +134,82 @@
|
||||
|
||||
---
|
||||
|
||||
## 4. Атрибуция
|
||||
## 4. Таблицы журнала
|
||||
|
||||
**Правило.** Колонка журнала объявляет свою ширину: служебные — через `width`,
|
||||
содержательная — через `min-width`.
|
||||
|
||||
Без этого Element Plus делит доступную ширину между колонками практически
|
||||
поровну. У журнала колонок три, поэтому уровень и время получали по трети
|
||||
строки, а сообщение — единственное содержимое журнала — тоже треть.
|
||||
|
||||
**Сообщение переносится, а не обрезается.** У Hysteria в `msg` приезжает
|
||||
диагностический JSON; строка, обрезанная многоточием, не отвечает ни на один
|
||||
вопрос, ради которого страницу открыли.
|
||||
|
||||
**Обе страницы журнала построены на одном компоненте**
|
||||
(`components/LogViewer`). Они были побайтово одинаковы и несли одни и те же три
|
||||
дефекта в двух экземплярах — ширины, обработку отказа выгрузки и форму ответа.
|
||||
Собственная `el-table-column` на странице журнала запрещена гейтом приёмки.
|
||||
|
||||
---
|
||||
|
||||
## 5. Выгрузка файлов
|
||||
|
||||
**Правило.** Сборка ссылки на скачивание существует в панели в единственном
|
||||
экземпляре — `utils/download.ts`. Единственность проверяется контрактным
|
||||
тестом по вхождению `createObjectURL`.
|
||||
|
||||
Копий было четыре, и все успели разойтись. Две из них ставили сетевой запрос
|
||||
ПЕРЕД `try`:
|
||||
|
||||
```ts
|
||||
const response = await exportApi(...); // отказ сюда не попадает
|
||||
try { ... } catch (e) { /* empty */ }
|
||||
```
|
||||
|
||||
то есть отказ самого запроса не ловился вовсе, а всё внутри глушилось молча:
|
||||
оператор не получал ни файла, ни причины. Третья падала на `split(...)` при
|
||||
отсутствующем `Content-Disposition` — и это исключение тоже глушилось.
|
||||
|
||||
**Отказ выгрузки показывает сама страница.** Бинарный ответ не проходит через
|
||||
общий разбор конверта: у `Blob` нет полей `code` и `errors`, поэтому
|
||||
перехватчик по нему фразы не даст.
|
||||
|
||||
---
|
||||
|
||||
## 6. Меню действий над строкой
|
||||
|
||||
**Правило.** Пункты `el-dropdown` объявляют `command`; обработчик — один, на
|
||||
`el-dropdown`.
|
||||
|
||||
`@click` на каждом пункте не запрещён самим Element Plus, но `command` является
|
||||
штатным контрактом именно для меню действий, и при нём невозможно добавить
|
||||
пункт, забыв его подключить. Обе половины проверяются контрактным тестом:
|
||||
наличие `@command` и отсутствие `@click` на пунктах.
|
||||
|
||||
**Частичный результат операции отличается от отказа кодом.** Отзыв доступа к
|
||||
VPN состоит из двух половин — записи в базе и разрыва активной сессии, — и
|
||||
первая может примениться без второй. Панель обязана распознать
|
||||
`peer_disconnect_failed` по коду, показать его предупреждением, а не ошибкой, и
|
||||
ОБНОВИТЬ строку: состояние в базе уже изменилось. Показ его как обычной ошибки
|
||||
подтолкнул бы оператора к выводу, прямо противоположному истине.
|
||||
|
||||
---
|
||||
|
||||
## 7. Подтверждения
|
||||
|
||||
**Правило.** Отмена подтверждения — это ответ оператора, а не ошибка.
|
||||
|
||||
`ElMessageBox` отклоняет промис при нажатии «Отмена». `await
|
||||
ElMessageBox.confirm(...)` без разбора отказа оставляет необработанное
|
||||
отклонение промиса на каждую отмену. Единственный прямой вызов на странице
|
||||
пиров живёт внутри `confirmAction`, переводящей отмену в обычное `false`; это
|
||||
закреплено тестом.
|
||||
|
||||
---
|
||||
|
||||
## 8. Атрибуция
|
||||
|
||||
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
|
||||
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
||||
|
||||
Reference in New Issue
Block a user