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:
2026-09-01 17:17:17 +05:00
parent c0a43ae915
commit 162759c599
37 changed files with 3072 additions and 343 deletions
+180
View File
@@ -54,6 +54,186 @@ Hardening-проход перед релизом `1.0.0`. Основная те
действовал, потому что цвет был вписан в сами ассеты. Ветка «сессия истекла,
войдите заново» существовала — и была недостижима сразу по двум причинам.
Десятый проход — разбор кода после девятого, на коммите `c0a43ae9`, со сверкой
Hysteria-интеграции с официальной документацией Hysteria 2. Тема прохода:
операции, у которых есть только одна из двух необходимых половин. Отключение
пира записывало запрет и не разрывало его сессию. Ограничение устройств
проверялось, пока отвечал Traffic Stats API, и молча снималось, когда он не
отвечал. Журнал собирал структурные поля и записывал пустое сообщение. Разбор
задокументирован в
[docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md](docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md).
### Исправлено — отзыв доступа к VPN
- **Отключение пира не отзывало доступ.** Запись `disabled=1` видит только
выборка в `Hysteria2Auth`, то есть она закрывает БУДУЩИЕ обращения к
HTTP-auth; установленная QUIC-сессия живёт своей жизнью и сама не
разрывается. После «Отключить» пир продолжал пользоваться доступом сколько
угодно долго, пока не переподключался по своей воле, — а панель показывала
его отключённым. Официальная документация Hysteria описывает `/kick` и
блокировку в auth backend как пару: по отдельности не работает ни одна
половина.
Появился `service.DisconnectPeers` — только официальный Traffic Stats
`/kick`, без единой записи в базу. Прежний `Hysteria2Kick` вместе с разрывом
проставлял `banned_until`, поэтому воспользоваться им для отключения было
нельзя: операция записала бы заодно временную блокировку — другой механизм с
другим сроком жизни.
Порядок обратному не подлежит: сначала долговременная запись, затем разрыв.
При обратном клиент успевает переподключиться в окне между `/kick` и записью.
Порядок доказывается снимком состояния базы В МОМЕНТ прихода `/kick` — после
операции оба шага уже выполнены и проверять там нечего.
- **Неудача разрыва не откатывает запрет.** Безопасная половина достигнута, и
возвращать пиру полный доступ из-за отказа второго шага нельзя. Частичный
результат сообщается отдельным кодом `peer_disconnect_failed`: без него
оператор прочитал бы обычную ошибку как «не сработало, состояние прежнее» —
вывод, прямо противоположный истине. Панель показывает его предупреждением и
обновляет строку.
- **Временная блокировка отчитывалась отказом, будучи применённой.** `KickPeer`
писал `banned_until`, затем звал `Hysteria2Kick`, который писал то же
значение второй раз, а до этого мог отказать на проверке состояния службы —
не сделав ничего, но уже после первой записи. Обе операции переведены на один
примитив.
- **`Hysteria2IsRunning` убран с путей принятия решений.** `util.Exec`
схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не
удалось» в одну ошибку, поэтому его ответ не является основанием ни для
отказа операции, ни для её пропуска. Значению осталось место в отображении,
где ошибочное «выключено» стоит одной неверной плашки.
- **Ограничение устройств стало fail-closed.** Отказ `/online` обрабатывался
возвратом успеха авторизации, то есть недоступность внутреннего `127.0.0.1`
превращала объявленный в панели «Лимит устройств: 3» в безлимит; следом была
только строка `warn` в журнале.
Вторая половина той же дыры была тише: `Hysteria2Online` отдавал ПУСТУЮ КАРТУ
БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», — и лимит переставал
проверяться вообще без единой записи. Пути разделены по назначению: терпимый
для отображения, строгий для решения о доступе.
Направление выбрано осознанно: запрос авторизации приходит ОТ Hysteria,
значит она жива, а её Traffic Stats API слушает loopback внутри того же
процесса. У `maxDevices` есть `min=1`, безлимита не бывает, поэтому такой
отказ затрагивает всех пиров сразу — и пишется уровнем `error`.
- **Секрет Traffic Stats API читался без проверки на nil** прямо на пути
machine-auth, то есть при каждом подключении пира: строка в таблице `config`
без значения уронила бы админку паникой на разыменовании.
- **Ошибка публичного порта больше не называет транспорт.** Публичный endpoint
Hysteria — UDP/QUIC, а сообщение утверждало `must be a valid TCP port`.
### Исправлено — журнал и страницы, которые его показывают
- **Журнал запросов писал пустой `msg`.** `entry.Info()`, `entry.Warn()` и
`entry.Error()` вызывались без аргумента сообщения, и logrus честно
записывал `"msg":""` для каждого HTTP-запроса. Пустой столбец на странице
системных логов был точным отражением того, что записал backend, а не
рассогласованием модели отображения с форматом файла.
`middleware.RequestLogMessage` собирает строку из тех же величин, что уже
лежат в структурных полях. Query-строка в неё не попадает: контракт
безопасности сильнее удобства чтения, и тест проверяет обе половины сразу —
сообщение непустое И не несёт ни machine token, ни знака `?`.
- **Страница системных логов не работала, пока нет файла журнала.** Эта ветка
отвечала голым массивом вместо `{records, total}`, панель читала
`data.records` и передавала `undefined` в таблицу. То есть на
свежепоставленном хосте — ровно в сценарии приёмки чистой установки —
страница была сломана целиком.
- **Одна битая строка ломала весь ответ.** При неразбираемой записи
выполнялись `vo.Fail(...)` И `continue`: ответ уже записан в поток, цикл идёт
дальше, а в конце безусловно выполняется `vo.Success`. Клиент получал два
JSON-документа подряд. Строка при этом бьётся штатно — lumberjack ротирует
файл, и обрыв последней записи на границе ротации обычен. Теперь такая строка
пропускается: остальные записи прочитаны и полезны.
### Исправлено — панель оператора, второй заход
- **Быстрого включения/отключения пира в меню не было вовсе.** Сменить
состояние можно было только через форму изменения, хотя это самое частое
действие оператора.
- **Меню действий переведено на `command`.** `@click` на каждом
`el-dropdown-item` заменён штатным контрактом `el-dropdown`: команда
приходит в одно место, и добавить пункт, забыв его подключить, становится
невозможно.
- **Колонки таблиц журнала не задавали ширину.** Без `width`/`min-width`
Element Plus делит доступную ширину практически поровну, и сообщение —
единственное содержимое журнала — получало треть строки. Служебные колонки
зафиксированы, колонка сообщения растягивается за счёт остатка и
переносится, а не обрезается многоточием: у Hysteria в `msg` приезжает
диагностический JSON.
- **Обе страницы журнала построены на общем `LogViewer`.** Они были побайтово
одинаковы и несли одни и те же дефекты в двух экземплярах; починка в двух
местах разошлась бы снова.
- **Отказ выгрузки журнала не ловился и глушился.** Сетевой запрос стоял ПЕРЕД
`try`, поэтому его отказ этим `catch` не ловился вовсе, а сам `catch` был
пуст. Оператор нажимал «Экспорт» и не получал ни файла, ни причины.
- **Сборка ссылки на скачивание сведена в `utils/download.ts`.** Копий было
четыре, и все успели разойтись: две ставили запрос вне `try`, третья падала
на `split(...)` при отсутствующем `Content-Disposition` — и это исключение
тоже глушилось. Четвёртую копию (выгрузка конфига Hysteria) нашёл
контрактный тест, потребовавший единственности `createObjectURL`.
- **Ширина всплывающей подсказки ограничена штатным `popper-style`.** Без
ограничения длинный перевод получал естественную ширину popper и
растягивался почти на весь экран одной строкой.
- **Срок временной блокировки называется оператору.** `Date.now() + час` был
зашит в обработчик, не подтверждался и не сообщался ни до, ни после; ключи
локализации для него существовали и были мёртвыми. Пункты переименованы так,
чтобы «временно заблокировать» не путалось с «отключить пир».
- **Отмена подтверждения перестала быть ошибкой.** `ElMessageBox` отклоняет
промис при нажатии «Отмена», и `await ElMessageBox.confirm(...)` без разбора
отказа оставлял необработанное отклонение промиса — в четырёх местах страницы
пиров и ещё одном в верхней панели.
- **Подсказка имени пира описывала не действующее правило.** Она обещала
«латиница, цифры и дефис», тогда как `service.IsValidPeerName` принимает
`a-z A-Z 0-9 !@#$%^&*()_+-=`: подсказка осталась от правила, действовавшего
до EX-03, и оператор не имел причин пробовать разрешённые символы. Тест
читает набор из серверной константы `PeerNameCharset`.
- **Типы полей журнала в панели расходились с сервером** (`latencyTime` и
`statusCode` объявлены строками при `int64` на сервере). Пока колонки не
отображались, расхождение было безвредным.
- **Диагностика слоя proxy сообщала `err: <nil>` именно при отказе API.**
Ветка `err != nil || resp.StatusCode != http.StatusOK` печатала `err`, то
есть при ответе Hysteria с кодом, отличным от 200, в журнал уезжала запись
без единого факта. Различать эти два случая стало обязательно: на этом пути
теперь стоит отзыв доступа.
- **Отказ показывался дважды, а транспортный — ни разу.** Действия строки пира
не объявляли `skipErrorToast`, поэтому после появления собственных сообщений
отказ показывался и перехватчиком, и страницей: у частичного результата
отзыва доступа это давало предупреждение «состояние применено наполовину» и
рядом ошибку — два противоречащих сообщения об одном событии.
Обратная половина существовала и раньше: флаг гасил не только отказ API, но и
ТРАНСПОРТНЫЙ отказ, при том что все объявлявшие его страницы писали в своих
обработчиках «транспортный отказ уже показан общим перехватчиком» и молчали.
Обрыв соединения при сохранении или удалении пира не показывал оператору
ничего. Флаг отнесён только к отказу API — тому, у которого есть конверт с
`code` и `errors`, то есть есть что разбирать.
- **Из globals ESLint убраны `DialogType` и `OptionType`** — их нет ни в
`src/types/global.d.ts`, ни где-либо в панели; список приведён к
фактическому содержимому файла объявлений. Добавлены `IdDto`, `BaseDto` и
`PageVo`, объявленные там на самом деле: `no-undef` не знает о
`declare global`, и отсутствующее в списке имя падало ошибкой линтера на
совершенно корректном коде.
### Исправлено — панель оператора
- **Необязательный секрет пира был фактически обязателен.** Панель обещала