Files
HY2XS_flamy/docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md
T
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

421 lines
29 KiB
Markdown
Raw 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-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. проверить, что отмена любого подтверждения не оставляет ошибок в консоли
браузера.