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:
@@ -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