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:
+180
@@ -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`, и отсутствующее в списке имя падало ошибкой линтера на
|
||||
совершенно корректном коде.
|
||||
|
||||
### Исправлено — панель оператора
|
||||
|
||||
- **Необязательный секрет пира был фактически обязателен.** Панель обещала
|
||||
|
||||
Reference in New Issue
Block a user