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-поля;
|
||||
- выгружать конфиг с секретами в открытом виде.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user