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`, и отсутствующее в списке имя падало ошибкой линтера на
|
||||||
|
совершенно корректном коде.
|
||||||
|
|
||||||
### Исправлено — панель оператора
|
### Исправлено — панель оператора
|
||||||
|
|
||||||
- **Необязательный секрет пира был фактически обязателен.** Панель обещала
|
- **Необязательный секрет пира был фактически обязателен.** Панель обещала
|
||||||
|
|||||||
+33
-6
@@ -17,10 +17,20 @@ func LogSystem(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
exists := util.Exists(constant.SystemLogPath)
|
|
||||||
logSystemVos := make([]vo.LogSystemVo, 0)
|
logSystemVos := make([]vo.LogSystemVo, 0)
|
||||||
if !exists {
|
|
||||||
vo.Success(logSystemVos, c)
|
// Форма ответа одна для ВСЕХ веток: {records, total}.
|
||||||
|
//
|
||||||
|
// Раньше ветка «файла ещё нет» отвечала голым массивом:
|
||||||
|
//
|
||||||
|
// vo.Success(logSystemVos, c)
|
||||||
|
//
|
||||||
|
// Панель читает `data.records`, поэтому получала undefined и передавала
|
||||||
|
// его в `:data` таблицы. То есть на свежепоставленном хосте — до первой
|
||||||
|
// записи в журнал — страница системных логов была сломана целиком, и
|
||||||
|
// именно этот сценарий воспроизводится на каждой приёмке чистой установки.
|
||||||
|
if !util.Exists(constant.SystemLogPath) {
|
||||||
|
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{LogSystemVos: logSystemVos, Total: 0}, c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
numLine := 0
|
numLine := 0
|
||||||
@@ -38,9 +48,26 @@ func LogSystem(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
logSystemVo := vo.LogSystemVo{}
|
logSystemVo := vo.LogSystemVo{}
|
||||||
err := json.Unmarshal([]byte(line), &logSystemVo)
|
// Неразбираемая строка ПРОПУСКАЕТСЯ, а не отказывает запросу.
|
||||||
if err != nil {
|
//
|
||||||
vo.Fail("Unable to unmarshal log data", c)
|
// Было так:
|
||||||
|
//
|
||||||
|
// if err != nil {
|
||||||
|
// vo.Fail("Unable to unmarshal log data", c)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// — ответ записывался в поток и цикл шёл дальше, а в конце
|
||||||
|
// безусловно выполнялся vo.Success. Клиент получал два JSON-документа
|
||||||
|
// подряд, то есть невалидный ответ, и gin ругался на повторную запись
|
||||||
|
// заголовков. Достаточно было ОДНОЙ битой строки, чтобы страница
|
||||||
|
// перестала открываться целиком.
|
||||||
|
//
|
||||||
|
// Строка при этом бьётся штатно: lumberjack ротирует файл, и обрыв
|
||||||
|
// последней записи на границе ротации — обычное событие, а не признак
|
||||||
|
// повреждения журнала. Отказывать из-за него всей выдаче нечем
|
||||||
|
// оправдать: остальные записи прочитаны и полезны.
|
||||||
|
if err := json.Unmarshal([]byte(line), &logSystemVo); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Собственный журнал санитизируется так же, как чужой.
|
// Собственный журнал санитизируется так же, как чужой.
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Форма ответа страницы системных логов.
|
||||||
|
//
|
||||||
|
// Панель читает `data.records` и передаёт результат прямо в `:data` таблицы.
|
||||||
|
// Поэтому ответ обязан нести records/total на КАЖДОЙ ветке — иначе таблица
|
||||||
|
// получает undefined и страница ломается целиком.
|
||||||
|
|
||||||
|
type logPageEnvelope struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data *struct {
|
||||||
|
Records []map[string]any `json:"records"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestSystemLog выполняет запрос к странице системных логов, подставив
|
||||||
|
// вместо журнала переданное содержимое. Пустой content означает «файла нет».
|
||||||
|
func requestSystemLog(t *testing.T, content *string) (logPageEnvelope, string) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "hy2xs-admin.log")
|
||||||
|
if content != nil {
|
||||||
|
if err := os.WriteFile(path, []byte(*content), 0o600); err != nil {
|
||||||
|
t.Fatalf("не удалось записать тестовый журнал: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previous := constant.SystemLogPath
|
||||||
|
constant.SystemLogPath = path
|
||||||
|
t.Cleanup(func() { constant.SystemLogPath = previous })
|
||||||
|
|
||||||
|
engine := gin.New()
|
||||||
|
engine.GET("/log/logSystem", LogSystem)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/log/logSystem?numLine=100", nil))
|
||||||
|
|
||||||
|
raw := recorder.Body.String()
|
||||||
|
var envelope logPageEnvelope
|
||||||
|
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
|
||||||
|
t.Fatalf("ответ не разбирается как один JSON-документ: %v\nтело: %s", err, raw)
|
||||||
|
}
|
||||||
|
return envelope, raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регрессия LOG-04. Ветка «файла ещё нет» отвечала ГОЛЫМ МАССИВОМ:
|
||||||
|
//
|
||||||
|
// vo.Success(logSystemVos, c)
|
||||||
|
//
|
||||||
|
// Панель получала `data.records === undefined` и передавала undefined в
|
||||||
|
// таблицу. То есть на свежепоставленном хосте — до первой записи в журнал —
|
||||||
|
// страница системных логов не работала вовсе, а это ровно тот сценарий,
|
||||||
|
// который проверяется на приёмке каждой чистой установки.
|
||||||
|
func TestLogSystemKeepsPageShapeWhenFileIsMissing(t *testing.T) {
|
||||||
|
envelope, raw := requestSystemLog(t, nil)
|
||||||
|
|
||||||
|
if envelope.Type != "ok" {
|
||||||
|
t.Fatalf("отсутствие журнала признано отказом: %s", raw)
|
||||||
|
}
|
||||||
|
if envelope.Data == nil {
|
||||||
|
t.Fatalf("ответ не несёт объекта страницы: %s", raw)
|
||||||
|
}
|
||||||
|
if envelope.Data.Records == nil {
|
||||||
|
t.Fatalf("ответ не несёт поля records: %s", raw)
|
||||||
|
}
|
||||||
|
if len(envelope.Data.Records) != 0 || envelope.Data.Total != 0 {
|
||||||
|
t.Fatalf("ожидалась пустая страница, получено %+v", *envelope.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регрессия LOG-05. При неразбираемой строке вызывался vo.Fail(...) и следом
|
||||||
|
// `continue`: ответ уже записан в поток, цикл идёт дальше, в конце безусловно
|
||||||
|
// выполняется vo.Success. Клиент получал два JSON-документа подряд — то есть
|
||||||
|
// невалидный ответ, — и одной битой строки хватало, чтобы страница перестала
|
||||||
|
// открываться целиком.
|
||||||
|
//
|
||||||
|
// Строка бьётся штатно: lumberjack ротирует файл, и обрыв последней записи на
|
||||||
|
// границе ротации — обычное событие.
|
||||||
|
func TestLogSystemSkipsBrokenLineWithoutBreakingResponse(t *testing.T) {
|
||||||
|
content := `{"level":"info","msg":"GET /api/peers → 200 (7 ms)","time":"2026-09-01 10:00:00"}
|
||||||
|
{"level":"info","msg":"обрыв записи на границе ротации
|
||||||
|
{"level":"warning","msg":"PATCH /api/peers/12 → 400 (4 ms)","time":"2026-09-01 10:00:01"}
|
||||||
|
`
|
||||||
|
envelope, raw := requestSystemLog(t, &content)
|
||||||
|
|
||||||
|
if envelope.Type != "ok" {
|
||||||
|
t.Fatalf("битая строка отказала всей выдаче: %s", raw)
|
||||||
|
}
|
||||||
|
if envelope.Data == nil {
|
||||||
|
t.Fatalf("ответ не несёт объекта страницы: %s", raw)
|
||||||
|
}
|
||||||
|
if len(envelope.Data.Records) != 2 {
|
||||||
|
t.Fatalf("ожидались две читаемые записи, получено %d: %+v", len(envelope.Data.Records), envelope.Data.Records)
|
||||||
|
}
|
||||||
|
for _, record := range envelope.Data.Records {
|
||||||
|
if message, _ := record["msg"].(string); message == "" {
|
||||||
|
t.Errorf("запись пришла с пустым msg: %+v", record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаемый журнал доезжает до панели в той же форме.
|
||||||
|
func TestLogSystemReturnsRecordsForReadableFile(t *testing.T) {
|
||||||
|
content := `{"level":"info","msg":"GET /api/peers → 200 (7 ms)","reqMethod":"GET","reqPath":"/api/peers","statusCode":200,"latencyTime":7,"time":"2026-09-01 10:00:00"}
|
||||||
|
`
|
||||||
|
envelope, raw := requestSystemLog(t, &content)
|
||||||
|
|
||||||
|
if envelope.Data == nil || len(envelope.Data.Records) != 1 {
|
||||||
|
t.Fatalf("читаемая запись не доехала до панели: %s", raw)
|
||||||
|
}
|
||||||
|
record := envelope.Data.Records[0]
|
||||||
|
if message, _ := record["msg"].(string); message == "" {
|
||||||
|
t.Fatalf("msg потерян при отдаче: %+v", record)
|
||||||
|
}
|
||||||
|
if path, _ := record["reqPath"].(string); path != "/api/peers" {
|
||||||
|
t.Fatalf("структурные поля не доехали: %+v", record)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,8 +285,12 @@ func KickPeer(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// failService, а не vo.Fail: отзыв доступа умеет завершиться ЧАСТИЧНО —
|
||||||
|
// блокировка записана, активную сессию завершить не удалось. Через
|
||||||
|
// vo.Fail этот результат уезжал бы панели неотличимо от полного отказа, и
|
||||||
|
// оператор сделал бы неверный вывод о состоянии пира.
|
||||||
if err = service.KickPeer(id, *kickDto.BannedUntil); err != nil {
|
if err = service.KickPeer(id, *kickDto.BannedUntil); err != nil {
|
||||||
vo.Fail(err.Error(), c)
|
failService(err, c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
vo.Success(nil, c)
|
vo.Success(nil, c)
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -37,6 +39,46 @@ func newPeerControllerDB(t *testing.T) {
|
|||||||
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
|
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// answerTrafficStats поднимает минимальный Traffic Stats API Hysteria.
|
||||||
|
//
|
||||||
|
// Нужен там, где тест доходит до service.Hysteria2Auth: проверка лимита
|
||||||
|
// устройств теперь fail-closed, и подключение без ответа Traffic Stats API
|
||||||
|
// отклоняется (см. AUTH-01/AUTH-02). Наблюдений этот ответчик не ведёт —
|
||||||
|
// контракт `/kick` и `/online` доказывается в пакете service; здесь он лишь
|
||||||
|
// делает вопрос «сколько устройств онлайн» отвечаемым.
|
||||||
|
func answerTrafficStats(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/online" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte("{}"))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", server.URL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
content := "listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:" + port + "\"\n secret: \"test-secret\"\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
previous := constant.Hysteria2ConfigPath
|
||||||
|
constant.Hysteria2ConfigPath = path
|
||||||
|
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||||||
|
|
||||||
|
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, "test-secret"); err != nil {
|
||||||
|
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// peerPayload — тело создания пира со всеми обязательными полями.
|
// peerPayload — тело создания пира со всеми обязательными полями.
|
||||||
// Тесты меняют в нём ровно то, что проверяют.
|
// Тесты меняют в нём ровно то, что проверяют.
|
||||||
func peerPayload(name string) map[string]any {
|
func peerPayload(name string) map[string]any {
|
||||||
@@ -92,6 +134,7 @@ func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) {
|
|||||||
for label, mutate := range cases {
|
for label, mutate := range cases {
|
||||||
t.Run(label, func(t *testing.T) {
|
t.Run(label, func(t *testing.T) {
|
||||||
newPeerControllerDB(t)
|
newPeerControllerDB(t)
|
||||||
|
answerTrafficStats(t)
|
||||||
|
|
||||||
body := peerPayload("client-01")
|
body := peerPayload("client-01")
|
||||||
mutate(body)
|
mutate(body)
|
||||||
|
|||||||
@@ -57,9 +57,17 @@ export default tseslint.config(
|
|||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...globals.node,
|
||||||
...autoImportGlobals,
|
...autoImportGlobals,
|
||||||
// Объявлены в src/types/global.d.ts и используются как глобальные типы.
|
// Объявлены в src/types/global.d.ts и используются как глобальные
|
||||||
DialogType: "readonly",
|
// типы. Списку положено совпадать с содержимым того файла: `no-undef`
|
||||||
OptionType: "readonly",
|
// не знает о declare global, и отсутствующее здесь имя падает ошибкой
|
||||||
|
// линтера при совершенно корректном коде.
|
||||||
|
//
|
||||||
|
// DialogType и OptionType отсюда убраны: в src/types/global.d.ts их
|
||||||
|
// нет и в панели они не встречаются — это остаток чужого шаблона,
|
||||||
|
// разрешавший необъявленные имена.
|
||||||
|
IdDto: "readonly",
|
||||||
|
BaseDto: "readonly",
|
||||||
|
PageVo: "readonly",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ export interface LogExportDto {
|
|||||||
|
|
||||||
export interface LogSystemVo {
|
export interface LogSystemVo {
|
||||||
clientIp: string;
|
clientIp: string;
|
||||||
latencyTime: string;
|
// Числа объявлены числами: сервер шлёт их из vo.LogSystemVo как int64
|
||||||
|
// (latencyTime, statusCode), а не строками. Пока обе колонки не
|
||||||
|
// отображались, расхождение было безвредным; после того как строка журнала
|
||||||
|
// стала читаемой, оно стало бы обычной ошибкой сравнения или форматирования.
|
||||||
|
latencyTime: number;
|
||||||
level: string;
|
level: string;
|
||||||
msg: string;
|
msg: string;
|
||||||
reqMethod: string;
|
reqMethod: string;
|
||||||
@@ -17,7 +21,7 @@ export interface LogSystemVo {
|
|||||||
reqPath: string;
|
reqPath: string;
|
||||||
// Только имена переданных query-параметров, без значений.
|
// Только имена переданных query-параметров, без значений.
|
||||||
reqQueryKeys: string;
|
reqQueryKeys: string;
|
||||||
statusCode: string;
|
statusCode: number;
|
||||||
time: string;
|
time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,20 @@ export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Действия над строкой пира сообщают свой исход сами (см. reportPeerActionError
|
||||||
|
// в списке пиров), поэтому общий тост им не нужен: он был бы вторым сигналом о
|
||||||
|
// том же событии.
|
||||||
|
//
|
||||||
|
// Для отзыва доступа это не косметика. Операция умеет завершиться ЧАСТИЧНО —
|
||||||
|
// запрет новых подключений записан, активную сессию завершить не удалось, — и
|
||||||
|
// страница показывает такой исход предупреждением. Общий перехватчик показал бы
|
||||||
|
// рядом ошибку, то есть два противоречащих друг другу сообщения об одном
|
||||||
|
// результате.
|
||||||
export function deletePeerApi(data: IdDto): AxiosPromise {
|
export function deletePeerApi(data: IdDto): AxiosPromise {
|
||||||
return request({
|
return request({
|
||||||
url: `/peers/${data.id}`,
|
url: `/peers/${data.id}`,
|
||||||
method: "delete",
|
method: "delete",
|
||||||
|
skipErrorToast: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +65,7 @@ export function resetPeerTrafficApi(data: IdDto): AxiosPromise {
|
|||||||
return request({
|
return request({
|
||||||
url: `/peers/${data.id}/reset-traffic`,
|
url: `/peers/${data.id}/reset-traffic`,
|
||||||
method: "patch",
|
method: "patch",
|
||||||
|
skipErrorToast: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +73,7 @@ export function releaseKickPeerApi(data: IdDto): AxiosPromise {
|
|||||||
return request({
|
return request({
|
||||||
url: `/peers/${data.id}/release-kick`,
|
url: `/peers/${data.id}/release-kick`,
|
||||||
method: "patch",
|
method: "patch",
|
||||||
|
skipErrorToast: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +82,7 @@ export function kickPeerApi(id: number, data: KickPeerDto): AxiosPromise {
|
|||||||
url: `/peers/${id}/kick`,
|
url: `/peers/${id}/kick`,
|
||||||
method: "patch",
|
method: "patch",
|
||||||
data,
|
data,
|
||||||
|
skipErrorToast: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="page-toolbar">
|
||||||
|
<el-form :model="queryParams" :inline="true">
|
||||||
|
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.numLine"
|
||||||
|
style="width: 200px"
|
||||||
|
@change="load"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in LINE_OPTIONS"
|
||||||
|
:key="option"
|
||||||
|
:label="String(option)"
|
||||||
|
:value="option"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="export">
|
||||||
|
<el-button :loading="exporting" @click="handleExport">
|
||||||
|
<template #icon>
|
||||||
|
<i-ep-download />
|
||||||
|
</template>
|
||||||
|
{{ $t("common.export") }}
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="refresh">
|
||||||
|
<el-button @click="load">
|
||||||
|
<template #icon>
|
||||||
|
<i-ep-refresh />
|
||||||
|
</template>
|
||||||
|
{{ $t("common.refresh") }}
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<el-table v-loading="loading" :data="records" class="log-table">
|
||||||
|
<!--
|
||||||
|
Ширины заданы явно и по назначению колонки.
|
||||||
|
|
||||||
|
Без width/min-width Element Plus делит доступную ширину между
|
||||||
|
колонками практически поровну: уровень и время получали по трети
|
||||||
|
строки, а сообщение — тоже треть, хотя именно оно и является
|
||||||
|
содержимым журнала. У Hysteria в msg приезжает диагностический JSON,
|
||||||
|
поэтому колонка сообщения растягивается за счёт остатка, а служебные
|
||||||
|
колонки зафиксированы.
|
||||||
|
-->
|
||||||
|
<el-table-column
|
||||||
|
prop="level"
|
||||||
|
label="level"
|
||||||
|
width="110"
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<el-table-column prop="msg" label="msg" min-width="640" align="left">
|
||||||
|
<template #default="scope">
|
||||||
|
<!--
|
||||||
|
Сообщение ПЕРЕНОСИТСЯ, а не обрезается многоточием: у Hysteria
|
||||||
|
это диагностический JSON, и обрезанная строка не отвечает ни на
|
||||||
|
один вопрос, ради которого страницу открыли.
|
||||||
|
-->
|
||||||
|
<span class="log-message">{{ scope.row.msg }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="time" label="time" width="200" align="center" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Общая страница журнала: системного и Hysteria.
|
||||||
|
*
|
||||||
|
* Обе страницы были побайтово одинаковы, кроме вызываемого API, и обе несли
|
||||||
|
* одни и те же три дефекта: колонки без ширин, экспорт с сетевым запросом ВНЕ
|
||||||
|
* try и пустой catch, глушивший всё остальное. Починка в двух местах разошлась
|
||||||
|
* бы снова — как разошлись форма ответа и обработка ошибок до этого.
|
||||||
|
*
|
||||||
|
* Различие между страницами вынесено в параметры: чем читать и что выгружать.
|
||||||
|
*/
|
||||||
|
import { AxiosPromise, AxiosResponse } from "axios";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
import { exportLogApi } from "@/api/log";
|
||||||
|
import { LogDto } from "@/api/log/types";
|
||||||
|
import { saveResponseAsFile } from "@/utils/download";
|
||||||
|
|
||||||
|
/** Строка журнала в объёме, который показывает таблица. */
|
||||||
|
interface LogRow {
|
||||||
|
level: string;
|
||||||
|
msg: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Чтение страницы журнала. */
|
||||||
|
fetchLogs: (query: LogDto) => AxiosPromise<PageVo<LogRow>>;
|
||||||
|
/** Значение option для выгрузки: 0 — журнал админки, 1 — журнал Hysteria. */
|
||||||
|
exportOption: number;
|
||||||
|
/** Имя файла, если сервер его не назвал. */
|
||||||
|
exportFileName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const LINE_OPTIONS = [100, 200, 300];
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const exporting = ref(false);
|
||||||
|
const records = ref<LogRow[]>([]);
|
||||||
|
const queryParams = reactive<LogDto>({ numLine: LINE_OPTIONS[0] });
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await props.fetchLogs(queryParams);
|
||||||
|
// Ответ обязан нести records на любой ветке — это контракт LogSystemPage.
|
||||||
|
// Запасное значение здесь стоит не вместо контракта, а чтобы отказ формы
|
||||||
|
// ответа не превращался в исключение внутри таблицы.
|
||||||
|
records.value = data?.records ?? [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выгрузка журнала.
|
||||||
|
*
|
||||||
|
* Сетевой запрос находится ВНУТРИ try. Раньше он стоял перед ним, поэтому его
|
||||||
|
* отказ не ловился этим catch вовсе, а сам catch был пуст: оператор не получал
|
||||||
|
* ни файла, ни причины.
|
||||||
|
*
|
||||||
|
* Отказ показывается ОДИН раз. Выгрузка приходит бинарным потоком и не
|
||||||
|
* проходит через общий разбор конверта — у Blob нет полей code и errors, —
|
||||||
|
* поэтому фразу здесь даёт сама страница.
|
||||||
|
*/
|
||||||
|
async function handleExport() {
|
||||||
|
exporting.value = true;
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse = await exportLogApi({
|
||||||
|
option: props.exportOption,
|
||||||
|
});
|
||||||
|
saveResponseAsFile(response, props.exportFileName);
|
||||||
|
ElMessage.success(t("common.downloadSuccess"));
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(t("log.exportFailed"));
|
||||||
|
} finally {
|
||||||
|
exporting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.log-message {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--el-font-family-mono, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -154,12 +154,18 @@ export default {
|
|||||||
credential_format: "“{field}”: contains characters that are not allowed",
|
credential_format: "“{field}”: contains characters that are not allowed",
|
||||||
rule_violated: "“{field}”: value is not acceptable",
|
rule_violated: "“{field}”: value is not acceptable",
|
||||||
validation_failed: "Validation failed",
|
validation_failed: "Validation failed",
|
||||||
body_invalid: "Request could not be parsed: check field formats and types",
|
body_invalid:
|
||||||
|
"Request could not be parsed: check field formats and types",
|
||||||
peer_name_taken: "A peer with this name already exists",
|
peer_name_taken: "A peer with this name already exists",
|
||||||
peer_name_reserved: "This name is reserved for the installer peer",
|
peer_name_reserved: "This name is reserved for the installer peer",
|
||||||
peer_bootstrap_identity_locked:
|
peer_bootstrap_identity_locked:
|
||||||
"The installer peer's name and secret are mirrored in a file on the server and cannot be changed from the panel. Delete the bootstrap peer entirely if it is no longer needed.",
|
"The installer peer's name and secret are mirrored in a file on the server and cannot be changed from the panel. Delete the bootstrap peer entirely if it is no longer needed.",
|
||||||
invalid_credentials: "Wrong username or password",
|
invalid_credentials: "Wrong username or password",
|
||||||
|
// The phrase must open with what has ALREADY been applied, otherwise it
|
||||||
|
// reads as "the operation failed" and the operator repeats an action
|
||||||
|
// that in fact went through.
|
||||||
|
peer_disconnect_failed:
|
||||||
|
"New connections for this peer are now refused, but its active session could not be terminated: the Hysteria Traffic Stats API is unreachable. An established connection may keep working until the client reconnects. Check the hysteria-server service and retry.",
|
||||||
import_file_extension: "Import accepts .json files only",
|
import_file_extension: "Import accepts .json files only",
|
||||||
unauthorized: "Signing in is required",
|
unauthorized: "Signing in is required",
|
||||||
session_expired: "Session expired",
|
session_expired: "Session expired",
|
||||||
@@ -185,8 +191,10 @@ export default {
|
|||||||
secret: "Secret",
|
secret: "Secret",
|
||||||
form: {
|
form: {
|
||||||
namePlaceholder: "client-01",
|
namePlaceholder: "client-01",
|
||||||
|
// The character set is stated as service.IsValidPeerName accepts it. The
|
||||||
|
// previous wording described the rule that was in force before EX-03.
|
||||||
nameHint:
|
nameHint:
|
||||||
"Peer identifier: 6 to 32 characters, latin letters, digits and hyphens. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
|
"Peer identifier: 6 to 32 characters — latin letters, digits and !@#$%^&*()_+-=. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
|
||||||
remarkPlaceholder: "laptop",
|
remarkPlaceholder: "laptop",
|
||||||
remarkHint: "Optional operator note. It is never shown to the client.",
|
remarkHint: "Optional operator note. It is never shown to the client.",
|
||||||
secretPlaceholder: "leave empty to generate one",
|
secretPlaceholder: "leave empty to generate one",
|
||||||
@@ -230,10 +238,22 @@ export default {
|
|||||||
conAt: "Last connection time",
|
conAt: "Last connection time",
|
||||||
createTime: "Create Time",
|
createTime: "Create Time",
|
||||||
releaseSuccess: "Release successful",
|
releaseSuccess: "Release successful",
|
||||||
kick: "Kick",
|
kick: "Ban temporarily",
|
||||||
kickTip: "Force user to log off",
|
kickTip:
|
||||||
releaseKick: "Release",
|
"Drop the session and refuse new connections until the ban expires",
|
||||||
releaseKickTip: "Remove offline status",
|
kickConfirm:
|
||||||
|
"Ban peer “{name}” until {until}? The current session will be dropped and new connections refused until then.",
|
||||||
|
kickSuccess: "Peer banned temporarily",
|
||||||
|
releaseKick: "Lift ban",
|
||||||
|
releaseKickTip: "Lift the temporary ban early",
|
||||||
|
// Disabling and the temporary ban are separate mechanisms: the ban expires
|
||||||
|
// on its own, disabling is only undone by hand.
|
||||||
|
enablePeer: "Enable peer",
|
||||||
|
disablePeer: "Disable peer",
|
||||||
|
disableConfirm:
|
||||||
|
"Disable peer “{name}”? The current session will be dropped and new connections refused until the peer is enabled again.",
|
||||||
|
enableSuccess: "Peer enabled",
|
||||||
|
disableSuccess: "Peer disabled",
|
||||||
createdAt: "Created at",
|
createdAt: "Created at",
|
||||||
bannedUntil: "Banned until",
|
bannedUntil: "Banned until",
|
||||||
totalTraffic: "Total traffic",
|
totalTraffic: "Total traffic",
|
||||||
@@ -264,6 +284,7 @@ export default {
|
|||||||
level: "Level",
|
level: "Level",
|
||||||
message: "Message",
|
message: "Message",
|
||||||
time: "Time",
|
time: "Time",
|
||||||
|
exportFailed: "Could not export the log",
|
||||||
},
|
},
|
||||||
errorPage: {
|
errorPage: {
|
||||||
back: "Back",
|
back: "Back",
|
||||||
|
|||||||
@@ -163,6 +163,11 @@ export default {
|
|||||||
peer_bootstrap_identity_locked:
|
peer_bootstrap_identity_locked:
|
||||||
"Имя и секрет пира установщика продублированы в файле на сервере и не меняются через панель. Ненужный bootstrap-пир следует удалить целиком.",
|
"Имя и секрет пира установщика продублированы в файле на сервере и не меняются через панель. Ненужный bootstrap-пир следует удалить целиком.",
|
||||||
invalid_credentials: "Неверный логин или пароль",
|
invalid_credentials: "Неверный логин или пароль",
|
||||||
|
// Фраза обязана начинаться с того, что УЖЕ СДЕЛАНО: иначе оператор
|
||||||
|
// прочитает её как «операция не выполнена» и повторит действие, которое
|
||||||
|
// на самом деле применилось.
|
||||||
|
peer_disconnect_failed:
|
||||||
|
"Новые подключения пира запрещены, но завершить его активную сессию не удалось: Traffic Stats API Hysteria недоступен. Установленное соединение может работать до переподключения клиента. Проверьте состояние службы hysteria-server и повторите действие.",
|
||||||
import_file_extension: "Импорт принимает только файлы .json",
|
import_file_extension: "Импорт принимает только файлы .json",
|
||||||
unauthorized: "Требуется вход в панель",
|
unauthorized: "Требуется вход в панель",
|
||||||
session_expired: "Сессия истекла",
|
session_expired: "Сессия истекла",
|
||||||
@@ -189,8 +194,12 @@ export default {
|
|||||||
secret: "Секрет",
|
secret: "Секрет",
|
||||||
form: {
|
form: {
|
||||||
namePlaceholder: "client-01",
|
namePlaceholder: "client-01",
|
||||||
|
// Набор символов назван так, как его понимает service.IsValidPeerName.
|
||||||
|
// Прежняя формулировка «латиница, цифры и дефис» осталась от правила,
|
||||||
|
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
|
||||||
|
// сервер принимает, и оператор не имел причин пробовать остальные.
|
||||||
nameHint:
|
nameHint:
|
||||||
"Идентификатор пира: от 6 до 32 символов, латиница, цифры и дефис. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
"Идентификатор пира: от 6 до 32 символов — латиница, цифры и знаки !@#$%^&*()_+-=. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
||||||
remarkPlaceholder: "ноутбук",
|
remarkPlaceholder: "ноутбук",
|
||||||
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
||||||
secretPlaceholder: "оставьте пустым — сгенерируем",
|
secretPlaceholder: "оставьте пустым — сгенерируем",
|
||||||
@@ -239,10 +248,21 @@ export default {
|
|||||||
conAt: "Последнее подключение",
|
conAt: "Последнее подключение",
|
||||||
createTime: "Создано",
|
createTime: "Создано",
|
||||||
releaseSuccess: "Ограничение снято",
|
releaseSuccess: "Ограничение снято",
|
||||||
kick: "Отключить",
|
kick: "Временно заблокировать",
|
||||||
kickTip: "Принудительно отключить пользователя",
|
kickTip: "Прервать соединение и не пускать до истечения срока",
|
||||||
releaseKick: "Снять",
|
kickConfirm:
|
||||||
releaseKickTip: "Снять офлайн-статус",
|
"Заблокировать пир «{name}» до {until}? Текущее соединение будет разорвано, новые подключения до этого времени приниматься не будут.",
|
||||||
|
kickSuccess: "Пир временно заблокирован",
|
||||||
|
releaseKick: "Снять блокировку",
|
||||||
|
releaseKickTip: "Снять временную блокировку досрочно",
|
||||||
|
// Отключение и временная блокировка — разные механизмы: у блокировки есть
|
||||||
|
// срок, который истекает сам, отключение снимается только руками.
|
||||||
|
enablePeer: "Включить пир",
|
||||||
|
disablePeer: "Отключить пир",
|
||||||
|
disableConfirm:
|
||||||
|
"Отключить пир «{name}»? Текущее соединение будет разорвано, а новые подключения запрещены, пока пир не будет включён обратно.",
|
||||||
|
enableSuccess: "Пир включён",
|
||||||
|
disableSuccess: "Пир отключён",
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
|
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
|
||||||
@@ -268,6 +288,7 @@ export default {
|
|||||||
level: "Уровень",
|
level: "Уровень",
|
||||||
message: "Сообщение",
|
message: "Сообщение",
|
||||||
time: "Время",
|
time: "Время",
|
||||||
|
exportFailed: "Не удалось выгрузить журнал",
|
||||||
},
|
},
|
||||||
errorPage: {
|
errorPage: {
|
||||||
back: "Назад",
|
back: "Назад",
|
||||||
|
|||||||
@@ -28,17 +28,24 @@ const { isFullscreen, toggle } = useFullscreen();
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Выход из системы.
|
* Выход из системы.
|
||||||
|
*
|
||||||
|
* Отмена диалога — это ОТВЕТ оператора, а не ошибка. ElMessageBox отклоняет
|
||||||
|
* промис при нажатии «Отмена», и цепочка без разбора отказа оставляла в
|
||||||
|
* консоли необработанное отклонение промиса на каждый передумавший выход.
|
||||||
*/
|
*/
|
||||||
function logout() {
|
async function logout() {
|
||||||
ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
|
try {
|
||||||
confirmButtonText: t("navbar.logout"),
|
await ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
|
||||||
cancelButtonText: t("common.cancel"),
|
confirmButtonText: t("navbar.logout"),
|
||||||
type: "warning",
|
cancelButtonText: t("common.cancel"),
|
||||||
}).then(() => {
|
type: "warning",
|
||||||
adminStore.logout().then(() => {
|
|
||||||
router.push(`/login?redirect=${route.fullPath}`);
|
|
||||||
});
|
});
|
||||||
});
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await adminStore.logout();
|
||||||
|
router.push(`/login?redirect=${route.fullPath}`);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+1
@@ -55,6 +55,7 @@ declare module 'vue' {
|
|||||||
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
|
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
|
||||||
ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default']
|
ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default']
|
||||||
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
|
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
|
||||||
|
LogViewer: typeof import('./../components/LogViewer/index.vue')['default']
|
||||||
MapAdd: typeof import('./../components/MapAdd/index.vue')['default']
|
MapAdd: typeof import('./../components/MapAdd/index.vue')['default']
|
||||||
Pagination: typeof import('./../components/Pagination/index.vue')['default']
|
Pagination: typeof import('./../components/Pagination/index.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export const ERR_CODE = {
|
|||||||
peerNameTaken: "peer_name_taken",
|
peerNameTaken: "peer_name_taken",
|
||||||
peerNameReserved: "peer_name_reserved",
|
peerNameReserved: "peer_name_reserved",
|
||||||
peerBootstrapLocked: "peer_bootstrap_identity_locked",
|
peerBootstrapLocked: "peer_bootstrap_identity_locked",
|
||||||
|
// ЧАСТИЧНЫЙ результат отзыва доступа, а не отказ операции: запрет новых
|
||||||
|
// подключений записан, активную сессию завершить не удалось. Панель
|
||||||
|
// показывает его предупреждением и обновляет строку — состояние в базе уже
|
||||||
|
// изменилось.
|
||||||
|
peerDisconnectFailed: "peer_disconnect_failed",
|
||||||
invalidCredentials: "invalid_credentials",
|
invalidCredentials: "invalid_credentials",
|
||||||
importFileExtension: "import_file_extension",
|
importFileExtension: "import_file_extension",
|
||||||
unauthorized: "unauthorized",
|
unauthorized: "unauthorized",
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачивание бинарного ответа API одним способом на всю панель.
|
||||||
|
*
|
||||||
|
* Раньше эта последовательность — Blob, createObjectURL, временная ссылка,
|
||||||
|
* клик, revokeObjectURL — была скопирована в трёх местах, и все три копии
|
||||||
|
* успели разойтись: страница пиров разбирала Content-Disposition с запасным
|
||||||
|
* именем файла, а обе страницы логов падали на `undefined.split(...)`, если
|
||||||
|
* заголовка не оказалось.
|
||||||
|
*
|
||||||
|
* Хуже расхождения был порядок на страницах логов:
|
||||||
|
*
|
||||||
|
* const response = await exportLogApi(...); // <- запрос ВНЕ try
|
||||||
|
* try { ... } catch (e) { } // <- и пустой catch
|
||||||
|
*
|
||||||
|
* Сетевой запрос стоял ПЕРЕД try, поэтому его отказ этим catch не ловился
|
||||||
|
* вовсе, а всё, что происходило внутри, глушилось молча. Оператор нажимал
|
||||||
|
* «Экспорт» и не получал ни файла, ни объяснения.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Имя файла по умолчанию, если сервер его не назвал. */
|
||||||
|
const CONTENT_DISPOSITION_PREFIX = "attachment; filename=";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имя файла из заголовка ответа.
|
||||||
|
*
|
||||||
|
* Отсутствующий или незнакомый заголовок — не отказ: у скачивания есть
|
||||||
|
* разумное запасное имя, и терять из-за формата заголовка уже полученный файл
|
||||||
|
* незачем.
|
||||||
|
*/
|
||||||
|
export function fileNameFromResponse(
|
||||||
|
response: AxiosResponse,
|
||||||
|
fallback: string
|
||||||
|
): string {
|
||||||
|
const header = response.headers?.["content-disposition"];
|
||||||
|
if (typeof header !== "string") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const index = header.indexOf(CONTENT_DISPOSITION_PREFIX);
|
||||||
|
if (index < 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const name = header.slice(index + CONTENT_DISPOSITION_PREFIX.length).trim();
|
||||||
|
return name === "" ? fallback : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отдаёт бинарный ответ пользователю как файл.
|
||||||
|
*
|
||||||
|
* Временная ссылка УДАЛЯЕТСЯ из документа, а objectURL освобождается в
|
||||||
|
* `finally`. Прежние копии оставляли `<a>` в body навсегда — по одному
|
||||||
|
* элементу на каждое нажатие «Экспорт» за всю жизнь вкладки.
|
||||||
|
*/
|
||||||
|
export function saveResponseAsFile(
|
||||||
|
response: AxiosResponse,
|
||||||
|
fallbackFileName: string
|
||||||
|
): void {
|
||||||
|
const blob = new Blob([response.data], { type: "application/octet-stream" });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
|
||||||
|
try {
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileNameFromResponse(response, fallbackFileName);
|
||||||
|
anchor.style.display = "none";
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
} finally {
|
||||||
|
anchor.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,13 @@ const t = i18n.global.t;
|
|||||||
* Запрос может отказаться от общего тоста, если показывает причину сам.
|
* Запрос может отказаться от общего тоста, если показывает причину сам.
|
||||||
*
|
*
|
||||||
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
|
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
|
||||||
* второй сигнал тостом там только шумит.
|
* второй сигнал тостом там только шумит. Так же поступают действия над строкой
|
||||||
|
* пира: отзыв доступа умеет завершиться частично, и такой исход показывается
|
||||||
|
* предупреждением, а не ошибкой.
|
||||||
|
*
|
||||||
|
* Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code`
|
||||||
|
* и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего,
|
||||||
|
* и молчание о нём означало бы операцию, не происходящую без объяснений.
|
||||||
*/
|
*/
|
||||||
declare module "axios" {
|
declare module "axios" {
|
||||||
export interface AxiosRequestConfig {
|
export interface AxiosRequestConfig {
|
||||||
@@ -138,9 +144,21 @@ service.interceptors.response.use(
|
|||||||
? t("common.systemError")
|
? t("common.systemError")
|
||||||
: t("common.networkError");
|
: t("common.networkError");
|
||||||
|
|
||||||
if (!error.config?.skipErrorToast) {
|
// skipErrorToast здесь НЕ действует, и это существенно.
|
||||||
ElMessage.error(message);
|
//
|
||||||
}
|
// Флаг существует ради одного случая: страница разбирает причину отказа
|
||||||
|
// сама — раскладывает её по полям формы или отличает частичный результат
|
||||||
|
// от полного отказа по коду. Всё это возможно только для отказа API, у
|
||||||
|
// которого есть конверт с `code` и `errors`.
|
||||||
|
//
|
||||||
|
// У транспортного отказа конверта нет вовсе. Страница о нём не может
|
||||||
|
// сказать ничего, кроме того же «сервер не отвечает», и все они пишут в
|
||||||
|
// своих обработчиках `// транспортный отказ уже показан общим
|
||||||
|
// перехватчиком» и молчат. Пока флаг гасил и эту ветку, утверждение было
|
||||||
|
// ложным: обрыв соединения при сохранении пира, его удалении или отзыве
|
||||||
|
// доступа не показывал оператору НИЧЕГО — операция просто не происходила
|
||||||
|
// молча.
|
||||||
|
ElMessage.error(message);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1050,6 +1050,7 @@ import Outbounds from "./components/Outbounds/index.vue";
|
|||||||
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
|
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { deepCopy } from "@/utils/copy";
|
import { deepCopy } from "@/utils/copy";
|
||||||
|
import { saveResponseAsFile } from "@/utils/download";
|
||||||
import { dashboardSummaryApi } from "@/api/dashboard";
|
import { dashboardSummaryApi } from "@/api/dashboard";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -1146,23 +1147,26 @@ const {
|
|||||||
hysteria2Monitor,
|
hysteria2Monitor,
|
||||||
} = toRefs(state);
|
} = toRefs(state);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выгрузка серверного конфига Hysteria.
|
||||||
|
*
|
||||||
|
* Тот же дефект, что был на обеих страницах журнала: сетевой запрос стоял
|
||||||
|
* ПЕРЕД try, поэтому его отказ этим catch не ловился вовсе, а сам catch был
|
||||||
|
* пуст. Вдобавок `dis.split(...)` падал на отсутствующем Content-Disposition —
|
||||||
|
* и это исключение тоже глушилось. Оператор нажимал «Экспорт» и не получал ни
|
||||||
|
* файла, ни причины.
|
||||||
|
*
|
||||||
|
* Сборка ссылки на скачивание живёт в одном месте на всю панель.
|
||||||
|
*/
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
let response = await exportHysteria2ConfigApi();
|
|
||||||
try {
|
try {
|
||||||
const blob = new Blob([response.data], {
|
const response = await exportHysteria2ConfigApi();
|
||||||
type: "application/octet-stream",
|
saveResponseAsFile(response, "hysteria-config.yaml");
|
||||||
});
|
ElMessage.success(t("common.downloadSuccess"));
|
||||||
let url = window.URL.createObjectURL(blob);
|
} catch {
|
||||||
let a = document.createElement("a");
|
// Выгрузка приходит бинарным потоком и не проходит через общий разбор
|
||||||
document.body.appendChild(a);
|
// конверта: у Blob нет полей code и errors.
|
||||||
a.href = url;
|
ElMessage.error(t("common.systemError"));
|
||||||
let dis = response.headers["content-disposition"];
|
|
||||||
a.download = dis.split("attachment; filename=")[1];
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
ElMessage.success(t("common.success"));
|
|
||||||
} catch (e) {
|
|
||||||
/* empty */
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,109 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<log-viewer
|
||||||
<div class="page-toolbar">
|
:fetch-logs="logHysteria2Api"
|
||||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
:export-option="1"
|
||||||
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
export-file-name="hysteria2.log"
|
||||||
<el-select
|
/>
|
||||||
v-model="queryParams.numLine"
|
|
||||||
style="width: 200px"
|
|
||||||
@change="setRecords"
|
|
||||||
>
|
|
||||||
<el-option label="100" value="100" />
|
|
||||||
<el-option label="200" value="200" />
|
|
||||||
<el-option label="300" value="300" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item prop="export">
|
|
||||||
<el-button @click="handleExport">
|
|
||||||
<template #icon>
|
|
||||||
<i-ep-download />
|
|
||||||
</template>
|
|
||||||
{{ $t("common.export") }}
|
|
||||||
</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item prop="refresh">
|
|
||||||
<el-button @click="setRecords">
|
|
||||||
<template #icon>
|
|
||||||
<i-ep-refresh />
|
|
||||||
</template>
|
|
||||||
{{ $t("common.refresh") }}
|
|
||||||
</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-card shadow="never">
|
|
||||||
<el-table v-loading="loading" :data="records">
|
|
||||||
<el-table-column
|
|
||||||
key="level"
|
|
||||||
label="level"
|
|
||||||
align="center"
|
|
||||||
prop="level"
|
|
||||||
/>
|
|
||||||
<el-table-column key="msg" label="msg" align="center" prop="msg" />
|
|
||||||
<el-table-column key="time" label="time" align="center" prop="time" />
|
|
||||||
</el-table>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
name: "index",
|
name: "LogHysteria",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LogDto, LogHysteria2Vo } from "@/api/log/types";
|
/**
|
||||||
import { exportLogApi, logHysteria2Api } from "@/api/log";
|
* Журнал Hysteria.
|
||||||
import { useI18n } from "vue-i18n";
|
*
|
||||||
|
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer — см.
|
||||||
const { t } = useI18n();
|
* страницу системного журнала.
|
||||||
|
*/
|
||||||
const state = reactive({
|
import LogViewer from "@/components/LogViewer/index.vue";
|
||||||
loading: true,
|
import { logHysteria2Api } from "@/api/log";
|
||||||
total: 0,
|
|
||||||
records: [] as LogHysteria2Vo[],
|
|
||||||
queryParams: {
|
|
||||||
numLine: 100,
|
|
||||||
} as LogDto,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { loading, records, queryParams } = toRefs(state);
|
|
||||||
|
|
||||||
const setRecords = async () => {
|
|
||||||
try {
|
|
||||||
state.loading = true;
|
|
||||||
const { data } = await logHysteria2Api(state.queryParams);
|
|
||||||
state.records = data.records;
|
|
||||||
state.total = data.total;
|
|
||||||
} finally {
|
|
||||||
state.loading = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
let response = await exportLogApi({ option: 1 });
|
|
||||||
try {
|
|
||||||
const blob = new Blob([response.data], {
|
|
||||||
type: "application/octet-stream",
|
|
||||||
});
|
|
||||||
let url = window.URL.createObjectURL(blob);
|
|
||||||
let a = document.createElement("a");
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.href = url;
|
|
||||||
let dis = response.headers["content-disposition"];
|
|
||||||
a.download = dis.split("attachment; filename=")[1];
|
|
||||||
// Имитация клика для скачивания
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
ElMessage.success(t("common.success"));
|
|
||||||
} catch (e) {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
setRecords();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,109 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<log-viewer
|
||||||
<div class="page-toolbar">
|
:fetch-logs="logSystemApi"
|
||||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
:export-option="0"
|
||||||
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
export-file-name="hy2xs-admin.log"
|
||||||
<el-select
|
/>
|
||||||
v-model="queryParams.numLine"
|
|
||||||
style="width: 200px"
|
|
||||||
@change="setRecords"
|
|
||||||
>
|
|
||||||
<el-option label="100" value="100" />
|
|
||||||
<el-option label="200" value="200" />
|
|
||||||
<el-option label="300" value="300" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item prop="export">
|
|
||||||
<el-button @click="handleExport">
|
|
||||||
<template #icon>
|
|
||||||
<i-ep-download />
|
|
||||||
</template>
|
|
||||||
{{ $t("common.export") }}
|
|
||||||
</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item prop="refresh">
|
|
||||||
<el-button @click="setRecords">
|
|
||||||
<template #icon>
|
|
||||||
<i-ep-refresh />
|
|
||||||
</template>
|
|
||||||
{{ $t("common.refresh") }}
|
|
||||||
</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-card shadow="never">
|
|
||||||
<el-table v-loading="loading" :data="records">
|
|
||||||
<el-table-column
|
|
||||||
key="level"
|
|
||||||
label="level"
|
|
||||||
align="center"
|
|
||||||
prop="level"
|
|
||||||
/>
|
|
||||||
<el-table-column key="msg" label="msg" align="center" prop="msg" />
|
|
||||||
<el-table-column key="time" label="time" align="center" prop="time" />
|
|
||||||
</el-table>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
name: "index",
|
name: "LogSystem",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LogDto, LogSystemVo } from "@/api/log/types";
|
/**
|
||||||
import { exportLogApi, logSystemApi } from "@/api/log";
|
* Журнал админки.
|
||||||
import { useI18n } from "vue-i18n";
|
*
|
||||||
|
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer: страница
|
||||||
const { t } = useI18n();
|
* системного журнала и страница журнала Hysteria были побайтово одинаковы и
|
||||||
|
* несли одни и те же дефекты в двух экземплярах.
|
||||||
const state = reactive({
|
*/
|
||||||
loading: true,
|
import LogViewer from "@/components/LogViewer/index.vue";
|
||||||
total: 0,
|
import { logSystemApi } from "@/api/log";
|
||||||
records: [] as LogSystemVo[],
|
|
||||||
queryParams: {
|
|
||||||
numLine: 100,
|
|
||||||
} as LogDto,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { loading, records, queryParams } = toRefs(state);
|
|
||||||
|
|
||||||
const setRecords = async () => {
|
|
||||||
try {
|
|
||||||
state.loading = true;
|
|
||||||
const { data } = await logSystemApi(state.queryParams);
|
|
||||||
state.records = data.records;
|
|
||||||
state.total = data.total;
|
|
||||||
} finally {
|
|
||||||
state.loading = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
let response = await exportLogApi({ option: 0 });
|
|
||||||
try {
|
|
||||||
const blob = new Blob([response.data], {
|
|
||||||
type: "application/octet-stream",
|
|
||||||
});
|
|
||||||
let url = window.URL.createObjectURL(blob);
|
|
||||||
let a = document.createElement("a");
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.href = url;
|
|
||||||
let dis = response.headers["content-disposition"];
|
|
||||||
a.download = dis.split("attachment; filename=")[1];
|
|
||||||
// Имитация клика для скачивания
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
ElMessage.success(t("common.success"));
|
|
||||||
} catch (e) {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
setRecords();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -30,7 +30,17 @@
|
|||||||
>
|
>
|
||||||
<el-button>{{ $t("common.import") }}</el-button>
|
<el-button>{{ $t("common.import") }}</el-button>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<el-tooltip :content="$t('peer.exportSettingsHint')" placement="bottom">
|
<!--
|
||||||
|
Ширина всплывающей подсказки ограничена штатным popper-style, а не
|
||||||
|
глобальным CSS. Без ограничения popper берёт естественную ширину
|
||||||
|
содержимого, и длинная подсказка растягивалась почти на весь экран
|
||||||
|
одной строкой.
|
||||||
|
-->
|
||||||
|
<el-tooltip
|
||||||
|
:content="$t('peer.exportSettingsHint')"
|
||||||
|
placement="bottom"
|
||||||
|
:popper-style="TOOLTIP_POPPER_STYLE"
|
||||||
|
>
|
||||||
<el-button class="ml-2" @click="handleExport">{{
|
<el-button class="ml-2" @click="handleExport">{{
|
||||||
$t("peer.exportSettings")
|
$t("peer.exportSettings")
|
||||||
}}</el-button>
|
}}</el-button>
|
||||||
@@ -132,35 +142,53 @@
|
|||||||
@click="copyUri(peerRow(scope.row))"
|
@click="copyUri(peerRow(scope.row))"
|
||||||
>{{ $t("peer.copyUri") }}</el-button
|
>{{ $t("peer.copyUri") }}</el-button
|
||||||
>
|
>
|
||||||
<el-dropdown trigger="click" placement="bottom-end">
|
<!--
|
||||||
|
Действия объявлены через command, а не обработчиками click на
|
||||||
|
каждом пункте. Это штатный API el-dropdown для меню действий:
|
||||||
|
команда приходит в одно место, и добавить пункт, забыв его
|
||||||
|
подключить, здесь уже нельзя.
|
||||||
|
-->
|
||||||
|
<el-dropdown
|
||||||
|
trigger="click"
|
||||||
|
placement="bottom-end"
|
||||||
|
@command="
|
||||||
|
(command: PeerCommand) =>
|
||||||
|
runCommand(command, peerRow(scope.row))
|
||||||
|
"
|
||||||
|
>
|
||||||
<el-button text circle class="peer-more-btn" @click.stop>
|
<el-button text circle class="peer-more-btn" @click.stop>
|
||||||
<i-ep-more-filled />
|
<i-ep-more-filled />
|
||||||
</el-button>
|
</el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item @click="showQr(peerRow(scope.row))">{{
|
<el-dropdown-item command="qr">{{
|
||||||
$t("peer.clientQr")
|
$t("peer.clientQr")
|
||||||
}}</el-dropdown-item>
|
}}</el-dropdown-item>
|
||||||
<el-dropdown-item
|
<el-dropdown-item command="edit">{{
|
||||||
@click="handleUpdate(peerRow(scope.row))"
|
$t("common.edit")
|
||||||
>{{ $t("common.edit") }}</el-dropdown-item
|
}}</el-dropdown-item>
|
||||||
>
|
<!--
|
||||||
<el-dropdown-item
|
Быстрое включение/отключение. Пункта здесь не было вовсе:
|
||||||
@click="handleResetTraffic(peerRow(scope.row))"
|
сменить состояние можно было только через форму
|
||||||
>{{ $t("common.resetTraffic") }}</el-dropdown-item
|
изменения, хотя это самое частое действие оператора.
|
||||||
>
|
-->
|
||||||
<el-dropdown-item @click="handleKick(peerRow(scope.row))">{{
|
<el-dropdown-item command="toggle-disabled">{{
|
||||||
|
scope.row.disabled === 1
|
||||||
|
? $t("peer.enablePeer")
|
||||||
|
: $t("peer.disablePeer")
|
||||||
|
}}</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="reset-traffic">{{
|
||||||
|
$t("common.resetTraffic")
|
||||||
|
}}</el-dropdown-item>
|
||||||
|
<el-dropdown-item divided command="kick">{{
|
||||||
$t("peer.kick")
|
$t("peer.kick")
|
||||||
}}</el-dropdown-item>
|
}}</el-dropdown-item>
|
||||||
<el-dropdown-item
|
<el-dropdown-item command="release-kick">{{
|
||||||
@click="handleReleaseKick(peerRow(scope.row))"
|
$t("peer.releaseKick")
|
||||||
>{{ $t("peer.releaseKick") }}</el-dropdown-item
|
}}</el-dropdown-item>
|
||||||
>
|
<el-dropdown-item divided command="delete">{{
|
||||||
<el-dropdown-item
|
$t("common.delete")
|
||||||
divided
|
}}</el-dropdown-item>
|
||||||
@click="handleDelete(peerRow(scope.row))"
|
|
||||||
>{{ $t("common.delete") }}</el-dropdown-item
|
|
||||||
>
|
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
@@ -214,7 +242,10 @@
|
|||||||
/>
|
/>
|
||||||
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
|
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="$t('peer.quota')" :error="serverErrors.quotaBytes">
|
<el-form-item
|
||||||
|
:label="$t('peer.quota')"
|
||||||
|
:error="serverErrors.quotaBytes"
|
||||||
|
>
|
||||||
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
|
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
|
||||||
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
|
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -231,7 +262,9 @@
|
|||||||
:error="serverErrors.maxDevices"
|
:error="serverErrors.maxDevices"
|
||||||
><el-input-number v-model="dataForm.maxDevices" :min="1"
|
><el-input-number v-model="dataForm.maxDevices" :min="1"
|
||||||
/></el-form-item>
|
/></el-form-item>
|
||||||
<el-form-item :label="$t('peer.disabled')" :error="serverErrors.disabled"
|
<el-form-item
|
||||||
|
:label="$t('peer.disabled')"
|
||||||
|
:error="serverErrors.disabled"
|
||||||
><el-switch v-model="disabledBool"
|
><el-switch v-model="disabledBool"
|
||||||
/></el-form-item>
|
/></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -381,8 +414,34 @@ import {
|
|||||||
PeerVo,
|
PeerVo,
|
||||||
} from "@/api/peer/types";
|
} from "@/api/peer/types";
|
||||||
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
|
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
|
||||||
import { isApiError } from "@/utils/api-error";
|
import { ERR_CODE, isApiError } from "@/utils/api-error";
|
||||||
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
|
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
|
||||||
|
import { saveResponseAsFile } from "@/utils/download";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ограничение ширины всплывающих подсказок.
|
||||||
|
*
|
||||||
|
* Объявлено значением, а не литералом в шаблоне: подсказок в панели больше
|
||||||
|
* одной, и «широкая» подсказка — это свойство панели, а не отдельной кнопки.
|
||||||
|
*/
|
||||||
|
const TOOLTIP_POPPER_STYLE = {
|
||||||
|
maxWidth: "360px",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Срок временной блокировки по умолчанию — один час. */
|
||||||
|
const KICK_DURATION_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Действия меню строки пира. */
|
||||||
|
type PeerCommand =
|
||||||
|
| "qr"
|
||||||
|
| "edit"
|
||||||
|
| "toggle-disabled"
|
||||||
|
| "reset-traffic"
|
||||||
|
| "kick"
|
||||||
|
| "release-kick"
|
||||||
|
| "delete";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Единственный переход от строки слота таблицы к модели пира.
|
* Единственный переход от строки слота таблицы к модели пира.
|
||||||
@@ -477,7 +536,10 @@ watch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const key of Object.keys(serverErrors)) {
|
for (const key of Object.keys(serverErrors)) {
|
||||||
if (next[key as keyof typeof next] !== previous[key as keyof typeof previous]) {
|
if (
|
||||||
|
next[key as keyof typeof next] !==
|
||||||
|
previous[key as keyof typeof previous]
|
||||||
|
) {
|
||||||
delete serverErrors[key];
|
delete serverErrors[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,12 +687,14 @@ async function submitForm() {
|
|||||||
const ok = await formRef.value.validate().catch(() => false);
|
const ok = await formRef.value.validate().catch(() => false);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
if (dialog.editId > 0 && dataForm.secret) {
|
// Отмена подтверждения — ответ оператора, а не ошибка: форма остаётся
|
||||||
await ElMessageBox.confirm(
|
// открытой с введённым, и в консоль ничего не улетает.
|
||||||
t("peer.secretRotateConfirm"),
|
if (
|
||||||
t("common.warning"),
|
dialog.editId > 0 &&
|
||||||
{ type: "warning" }
|
dataForm.secret &&
|
||||||
);
|
!(await confirmAction(t("peer.secretRotateConfirm")))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -685,39 +749,186 @@ function applyServerErrors(error: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единственная точка входа для действий меню строки.
|
||||||
|
*
|
||||||
|
* Раньше каждый пункт нёс собственный `@click`. Разница не только
|
||||||
|
* стилистическая: el-dropdown объявляет `command` штатным способом описывать
|
||||||
|
* меню действий, и при нём невозможно добавить пункт, забыв его подключить.
|
||||||
|
*/
|
||||||
|
function runCommand(command: PeerCommand, row: PeerVo) {
|
||||||
|
switch (command) {
|
||||||
|
case "qr":
|
||||||
|
return showQr(row);
|
||||||
|
case "edit":
|
||||||
|
return handleUpdate(row);
|
||||||
|
case "toggle-disabled":
|
||||||
|
return handleToggleDisabled(row);
|
||||||
|
case "reset-traffic":
|
||||||
|
return handleResetTraffic(row);
|
||||||
|
case "kick":
|
||||||
|
return handleKick(row);
|
||||||
|
case "release-kick":
|
||||||
|
return handleReleaseKick(row);
|
||||||
|
case "delete":
|
||||||
|
return handleDelete(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подтверждение, отмена которого является ОТВЕТОМ, а не ошибкой.
|
||||||
|
*
|
||||||
|
* ElMessageBox отклоняет промис при нажатии «Отмена». Прежний код писал
|
||||||
|
* `await ElMessageBox.confirm(...)` без разбора отказа, поэтому каждая отмена
|
||||||
|
* оставляла в консоли необработанное отклонение промиса — в четырёх местах
|
||||||
|
* страницы сразу.
|
||||||
|
*/
|
||||||
|
async function confirmAction(
|
||||||
|
message: string,
|
||||||
|
title = t("common.warning"),
|
||||||
|
options: Record<string, unknown> = { type: "warning" }
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(message, title, options);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отказ операции над пиром, показанный оператору один раз.
|
||||||
|
*
|
||||||
|
* Отдельно разбирается ЧАСТИЧНЫЙ результат отзыва доступа: запрет новых
|
||||||
|
* подключений в базу записан, а активную сессию завершить не удалось. Это не
|
||||||
|
* отказ операции, и показывать его как ошибку означало бы подтолкнуть
|
||||||
|
* оператора к неверному выводу «не сработало, состояние прежнее». Список при
|
||||||
|
* этом обновляется: строка обязана показать уже применённое состояние.
|
||||||
|
*/
|
||||||
|
function reportPeerActionError(error: unknown): void {
|
||||||
|
if (!isApiError(error)) {
|
||||||
|
// Транспортный отказ уже показан общим перехватчиком.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error.hasCode(ERR_CODE.peerDisconnectFailed)) {
|
||||||
|
ElMessage.warning(describeApiError(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessage.error(describeApiError(error));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(row: PeerVo) {
|
async function handleDelete(row: PeerVo) {
|
||||||
await ElMessageBox.confirm(
|
if (
|
||||||
t("common.deleteConfirm", { username: row.name }),
|
!(await confirmAction(t("common.deleteConfirm", { username: row.name })))
|
||||||
t("common.warning"),
|
) {
|
||||||
{ type: "warning" }
|
return;
|
||||||
);
|
}
|
||||||
await deletePeerApi({ id: row.id });
|
try {
|
||||||
|
await deletePeerApi({ id: row.id });
|
||||||
|
ElMessage.success(t("common.success"));
|
||||||
|
} catch (error) {
|
||||||
|
reportPeerActionError(error);
|
||||||
|
}
|
||||||
await handleQuery();
|
await handleQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResetTraffic(row: PeerVo) {
|
async function handleResetTraffic(row: PeerVo) {
|
||||||
await ElMessageBox.confirm(
|
if (!(await confirmAction(t("common.resetTrafficConfirm")))) {
|
||||||
t("common.resetTrafficConfirm"),
|
return;
|
||||||
t("common.warning"),
|
}
|
||||||
{ type: "warning" }
|
try {
|
||||||
);
|
await resetPeerTrafficApi({ id: row.id });
|
||||||
await resetPeerTrafficApi({ id: row.id });
|
ElMessage.success(t("common.success"));
|
||||||
|
} catch (error) {
|
||||||
|
reportPeerActionError(error);
|
||||||
|
}
|
||||||
await handleQuery();
|
await handleQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Быстрое включение/отключение пира.
|
||||||
|
*
|
||||||
|
* Отключение — это ОБЕ половины официального контракта Hysteria: запрет новых
|
||||||
|
* подключений в базе и разрыв уже установленной сессии через Traffic Stats
|
||||||
|
* `/kick`. Обе выполняет сервер; панели остаётся сообщить о результате, в том
|
||||||
|
* числе о частичном, и показать применённое состояние.
|
||||||
|
*
|
||||||
|
* Подтверждение спрашивается только на отключении: включение доступа обратимо
|
||||||
|
* одним движением, отключение обрывает работающее соединение.
|
||||||
|
*/
|
||||||
|
async function handleToggleDisabled(row: PeerVo) {
|
||||||
|
const disable = row.disabled !== 1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
disable &&
|
||||||
|
!(await confirmAction(t("peer.disableConfirm", { name: row.name })))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updatePeerApi({ id: row.id, disabled: disable ? 1 : 0 });
|
||||||
|
ElMessage.success(
|
||||||
|
disable ? t("peer.disableSuccess") : t("peer.enableSuccess")
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
reportPeerActionError(error);
|
||||||
|
}
|
||||||
|
// Список обновляется в любом случае: при частичном результате состояние в
|
||||||
|
// базе уже изменилось, и строка обязана это показать.
|
||||||
|
await handleQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Временная блокировка.
|
||||||
|
*
|
||||||
|
* Срок называется оператору явно. Раньше значение `Date.now() + час` было
|
||||||
|
* зашито в обработчик, нигде не показывалось и не подтверждалось: пункт меню
|
||||||
|
* «Отключить» молча блокировал пира на час, а сколько именно продлится
|
||||||
|
* блокировка, не сообщалось ни до, ни после.
|
||||||
|
*
|
||||||
|
* Механизм независим от «Отключить пир»: у временной блокировки свой срок,
|
||||||
|
* который истекает сам, и снимается она отдельным пунктом.
|
||||||
|
*/
|
||||||
async function handleKick(row: PeerVo) {
|
async function handleKick(row: PeerVo) {
|
||||||
await kickPeerApi(row.id, { bannedUntil: Date.now() + 60 * 60 * 1000 });
|
const bannedUntil = Date.now() + KICK_DURATION_MS;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(await confirmAction(
|
||||||
|
t("peer.kickConfirm", {
|
||||||
|
name: row.name,
|
||||||
|
until: timestampToDateTime(bannedUntil),
|
||||||
|
})
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await kickPeerApi(row.id, { bannedUntil });
|
||||||
|
ElMessage.success(t("peer.kickSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
reportPeerActionError(error);
|
||||||
|
}
|
||||||
await handleQuery();
|
await handleQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReleaseKick(row: PeerVo) {
|
async function handleReleaseKick(row: PeerVo) {
|
||||||
await releaseKickPeerApi({ id: row.id });
|
try {
|
||||||
|
await releaseKickPeerApi({ id: row.id });
|
||||||
|
ElMessage.success(t("peer.releaseSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
reportPeerActionError(error);
|
||||||
|
}
|
||||||
await handleQuery();
|
await handleQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyUri(row: PeerVo) {
|
async function copyUri(row: PeerVo) {
|
||||||
|
// Копирование без подтверждения выглядит как ничего не сделавшая кнопка:
|
||||||
|
// буфер обмена оператору не виден.
|
||||||
const { data } = await getPeerClientConfigApi(row.id);
|
const { data } = await getPeerClientConfigApi(row.id);
|
||||||
copy(data.url);
|
copy(data.url);
|
||||||
|
ElMessage.success(t("common.copySuccess"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showQr(row: PeerVo) {
|
async function showQr(row: PeerVo) {
|
||||||
@@ -752,17 +963,7 @@ function beforeImport(file: UploadRawFile) {
|
|||||||
async function downloadExport(includeSecrets: boolean) {
|
async function downloadExport(includeSecrets: boolean) {
|
||||||
try {
|
try {
|
||||||
const response = await exportPeerApi(includeSecrets);
|
const response = await exportPeerApi(includeSecrets);
|
||||||
const blob = new Blob([response.data], {
|
saveResponseAsFile(response, "peers-export.json");
|
||||||
type: "application/octet-stream",
|
|
||||||
});
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.href = url;
|
|
||||||
const dis = response.headers["content-disposition"] || "";
|
|
||||||
a.download = dis.split("attachment; filename=")[1] || "peers-export.json";
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
ElMessage.success(t("common.downloadSuccess"));
|
ElMessage.success(t("common.downloadSuccess"));
|
||||||
} catch {
|
} catch {
|
||||||
// Выгрузка приходит бинарным потоком, поэтому её отказ не проходит через
|
// Выгрузка приходит бинарным потоком, поэтому её отказ не проходит через
|
||||||
@@ -780,7 +981,7 @@ async function handleExport() {
|
|||||||
// Подтверждение здесь обязательно — это единственное место в панели, где
|
// Подтверждение здесь обязательно — это единственное место в панели, где
|
||||||
// оператор осознанно выгружает наружу работающие учётные данные доступа.
|
// оператор осознанно выгружает наружу работающие учётные данные доступа.
|
||||||
async function handleExportBackup() {
|
async function handleExportBackup() {
|
||||||
await ElMessageBox.confirm(
|
const confirmed = await confirmAction(
|
||||||
t("peer.exportBackupConfirm"),
|
t("peer.exportBackupConfirm"),
|
||||||
t("common.securityRisk"),
|
t("common.securityRisk"),
|
||||||
{
|
{
|
||||||
@@ -790,6 +991,9 @@ async function handleExportBackup() {
|
|||||||
confirmButtonClass: "el-button--danger",
|
confirmButtonClass: "el-button--danger",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await downloadExport(true);
|
await downloadExport(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-3
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -79,13 +80,37 @@ func LogHandler() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry := logrus.WithFields(fields)
|
entry := logrus.WithFields(fields)
|
||||||
|
message := RequestLogMessage(c.Request.Method, c.Request.URL.Path, statusCode, latencyTime)
|
||||||
|
|
||||||
if statusCode >= 500 {
|
if statusCode >= 500 {
|
||||||
entry.Error()
|
entry.Error(message)
|
||||||
} else if statusCode >= 400 {
|
} else if statusCode >= 400 {
|
||||||
entry.Warn()
|
entry.Warn(message)
|
||||||
} else {
|
} else {
|
||||||
entry.Info()
|
entry.Info(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequestLogMessage собирает человекочитаемое `msg` записи журнала.
|
||||||
|
//
|
||||||
|
// Раньше здесь стояли `entry.Error()`, `entry.Warn()` и `entry.Info()` — без
|
||||||
|
// аргумента. Поля собирались правильно, но logrus честно записывал `"msg":""`
|
||||||
|
// для КАЖДОГО HTTP-запроса, и страница системных логов показывала оператору
|
||||||
|
// пустой столбец сообщений. Дефект выглядел как рассогласование модели
|
||||||
|
// отображения с форматом файла, а был ровно тем, что backend записал.
|
||||||
|
//
|
||||||
|
// Строка собирается ИЗ ТЕХ ЖЕ величин, что уже лежат в структурных полях, и
|
||||||
|
// не добавляет к ним ничего нового. Это сознательно: запись остаётся
|
||||||
|
// машиночитаемой, а `msg` существует, чтобы человек мог прочитать её глазами,
|
||||||
|
// не собирая строку из шести колонок.
|
||||||
|
//
|
||||||
|
// Query-строка сюда не попадает, и это не небрежность, а действующий контракт
|
||||||
|
// безопасности. Hysteria обращается к машинному endpoint'у как
|
||||||
|
// /internal/hysteria/auth?access_token=<machine token> при каждом подключении
|
||||||
|
// пира; журнал отдаётся оператору через ExportLog и уезжает в
|
||||||
|
// diagnostics-бандл. Логируется путь, а из query — только имена параметров,
|
||||||
|
// отдельным полем (см. requestQueryKeys).
|
||||||
|
func RequestLogMessage(method string, path string, statusCode int, latency time.Duration) string {
|
||||||
|
return fmt.Sprintf("%s %s → %d (%d ms)", method, path, statusCode, latency.Milliseconds())
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -89,6 +90,71 @@ func TestLogHandlerOmitsQueryKeysWhenThereAreNone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Регрессия LOG-01: `entry.Info()` вызывался БЕЗ аргумента, и logrus честно
|
||||||
|
// записывал `"msg":""` для каждого HTTP-запроса. Страница системных логов
|
||||||
|
// показывала оператору пустой столбец сообщений — она отражала ровно то, что
|
||||||
|
// backend записал.
|
||||||
|
func TestLogHandlerWritesNonEmptyMessage(t *testing.T) {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/peers", nil)
|
||||||
|
|
||||||
|
logged := captureLog(t, request)
|
||||||
|
|
||||||
|
var record map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(logged)), &record); err != nil {
|
||||||
|
t.Fatalf("запись журнала не разбирается как JSON: %s", logged)
|
||||||
|
}
|
||||||
|
|
||||||
|
message, _ := record["msg"].(string)
|
||||||
|
if strings.TrimSpace(message) == "" {
|
||||||
|
t.Fatalf("запись журнала пришла с пустым msg: %s", logged)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `msg` обязан быть читаемым сам по себе: ради этого он и существует.
|
||||||
|
for _, fragment := range []string{"GET", "/api/peers", "200"} {
|
||||||
|
if !strings.Contains(message, fragment) {
|
||||||
|
t.Errorf("в msg нет %q: %q", fragment, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Структурные поля при этом остаются на месте — `msg` их не заменяет.
|
||||||
|
for _, field := range []string{"reqMethod", "reqPath", "statusCode", "latencyTime", "clientIP"} {
|
||||||
|
if _, ok := record[field]; !ok {
|
||||||
|
t.Errorf("структурное поле %q исчезло из записи: %s", field, logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Человекочитаемое сообщение НЕ является лазейкой для query-строки: контракт
|
||||||
|
// безопасности сильнее удобства чтения.
|
||||||
|
func TestLogHandlerMessageNeverCarriesQueryValues(t *testing.T) {
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/internal/hysteria/auth?access_token="+sentinel,
|
||||||
|
strings.NewReader("{}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
logged := captureLog(t, request)
|
||||||
|
|
||||||
|
var record map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(logged)), &record); err != nil {
|
||||||
|
t.Fatalf("запись журнала не разбирается как JSON: %s", logged)
|
||||||
|
}
|
||||||
|
|
||||||
|
message, _ := record["msg"].(string)
|
||||||
|
if strings.TrimSpace(message) == "" {
|
||||||
|
t.Fatalf("запись журнала пришла с пустым msg: %s", logged)
|
||||||
|
}
|
||||||
|
if strings.Contains(message, sentinel) {
|
||||||
|
t.Fatalf("machine token попал в msg: %q", message)
|
||||||
|
}
|
||||||
|
if strings.Contains(message, "?") {
|
||||||
|
t.Fatalf("query-строка попала в msg: %q", message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(message, "/internal/hysteria/auth") {
|
||||||
|
t.Fatalf("в msg нет пути запроса: %q", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Значение секретоподобного параметра не должно попасть в журнал ни на каком
|
// Значение секретоподобного параметра не должно попасть в журнал ни на каком
|
||||||
// маршруте, а не только на машинном.
|
// маршруте, а не только на машинном.
|
||||||
func TestLogHandlerRedactsQueryValuesOnOperatorRoutes(t *testing.T) {
|
func TestLogHandlerRedactsQueryValuesOnOperatorRoutes(t *testing.T) {
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ const (
|
|||||||
ErrCodeInvalidCredentials string = "invalid_credentials"
|
ErrCodeInvalidCredentials string = "invalid_credentials"
|
||||||
ErrCodeImportFileExtension string = "import_file_extension"
|
ErrCodeImportFileExtension string = "import_file_extension"
|
||||||
|
|
||||||
|
// ErrCodePeerDisconnectFailed — ЧАСТИЧНЫЙ результат, а не отказ операции.
|
||||||
|
//
|
||||||
|
// Отзыв доступа состоит из двух половин: долговременной записи в базе,
|
||||||
|
// которая закрывает новые подключения, и обращения к Traffic Stats `/kick`,
|
||||||
|
// которое рвёт уже установленную сессию. Первая половина к моменту этого
|
||||||
|
// кода уже применена и НЕ откатывается: достигнутое безопасное состояние
|
||||||
|
// нельзя отменять из-за неудачи второго шага — откат вернул бы пиру полный
|
||||||
|
// доступ.
|
||||||
|
//
|
||||||
|
// Поэтому код нужен отдельный. Без него оператор видел бы обычную ошибку и
|
||||||
|
// сделал бы ровно неверный вывод: «не сработало, состояние прежнее».
|
||||||
|
ErrCodePeerDisconnectFailed string = "peer_disconnect_failed"
|
||||||
|
|
||||||
// Коды состояния сессии. Панель различает «войдите» и «сессия кончилась»:
|
// Коды состояния сессии. Панель различает «войдите» и «сессия кончилась»:
|
||||||
// во втором случае оператор находится на рабочей странице, и молча
|
// во втором случае оператор находится на рабочей странице, и молча
|
||||||
// выбрасывать его на форму входа без объяснения нельзя.
|
// выбрасывать его на форму входа без объяснения нельзя.
|
||||||
|
|||||||
@@ -18,6 +18,32 @@ type Hysteria2Api struct {
|
|||||||
apiPort int64
|
apiPort int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// describeFailure объясняет, ПОЧЕМУ обращение к Traffic Stats API не удалось.
|
||||||
|
//
|
||||||
|
// Отказ здесь бывает двух видов, и раньше журнал различал их ровно наоборот
|
||||||
|
// тому, как нужно. Запись делалась так:
|
||||||
|
//
|
||||||
|
// if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
// logrus.Errorf("Hysteria2 KickUsers err: %v", err)
|
||||||
|
//
|
||||||
|
// то есть при отказе САМОГО API (200 не пришёл, а транспорт сработал) в журнал
|
||||||
|
// уезжало `err: <nil>` — сообщение без единого факта именно в том случае,
|
||||||
|
// когда Hysteria что-то ответила и её ответ был бы полезен. Транспортная
|
||||||
|
// ошибка при этом печаталась нормально, и внешне обе выглядели одинаково.
|
||||||
|
//
|
||||||
|
// Различать их стало обязательно: на этом пути теперь стоит отзыв доступа, и
|
||||||
|
// «сессию разорвать не удалось» — единственный след, по которому оператор
|
||||||
|
// поймёт, почему отключённый пир остался на связи.
|
||||||
|
func describeFailure(err error, resp *http.Response) string {
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
return fmt.Sprintf("unexpected status %s", resp.Status)
|
||||||
|
}
|
||||||
|
return "no response"
|
||||||
|
}
|
||||||
|
|
||||||
func NewHysteria2Api(apiPort int64) *Hysteria2Api {
|
func NewHysteria2Api(apiPort int64) *Hysteria2Api {
|
||||||
return &Hysteria2Api{
|
return &Hysteria2Api{
|
||||||
apiPort: apiPort,
|
apiPort: apiPort,
|
||||||
@@ -46,7 +72,7 @@ func (h *Hysteria2Api) ListUsers(clear bool, secret string) (map[string]bo.Hyste
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
logrus.Errorf("Hysteria2 ListUsers err: %v", err)
|
logrus.Errorf("Hysteria2 ListUsers failed: %s", describeFailure(err, resp))
|
||||||
return nil, errors.New("http connection error")
|
return nil, errors.New("http connection error")
|
||||||
}
|
}
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
@@ -86,7 +112,7 @@ func (h *Hysteria2Api) KickUsers(keys []string, secret string) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
logrus.Errorf("Hysteria2 KickUsers err: %v", err)
|
logrus.Errorf("Hysteria2 KickUsers failed: %s", describeFailure(err, resp))
|
||||||
return errors.New("http connection error")
|
return errors.New("http connection error")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -111,7 +137,7 @@ func (h *Hysteria2Api) OnlineUsers(secret string) (map[string]int64, error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
logrus.Errorf("Hysteria2 OnlineUsers err: %v", err)
|
logrus.Errorf("Hysteria2 OnlineUsers failed: %s", describeFailure(err, resp))
|
||||||
return nil, errors.New("http connection error")
|
return nil, errors.New("http connection error")
|
||||||
}
|
}
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
|||||||
@@ -26,7 +26,29 @@ func InitHysteria2() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hysteria2IsRunning — ответ systemd о состоянии службы, и НЕ БОЛЬШЕ ТОГО.
|
||||||
|
//
|
||||||
|
// Точность этого ответа ограничена сверху устройством util.Exec: он схлопывает
|
||||||
|
// «systemctl вернул 3, служба неактивна» и «запустить systemctl не удалось» в
|
||||||
|
// одну ошибку. Различить их здесь нельзя, поэтому false означает «служба
|
||||||
|
// неактивна ИЛИ спросить не получилось».
|
||||||
|
//
|
||||||
|
// Отсюда правило, которое стоило продукту двух дыр: на этом значении нельзя
|
||||||
|
// строить решения о доступе и о применении операции. Ему место в отображении
|
||||||
|
// (дашборд, признак online в списке), где ошибочное «выключено» стоит одной
|
||||||
|
// неверной плашки. Решения о доступе принимаются по фактическому ответу
|
||||||
|
// Traffic Stats API — см. hysteria2Online и DisconnectPeers.
|
||||||
func Hysteria2IsRunning() bool {
|
func Hysteria2IsRunning() bool {
|
||||||
|
return hysteria2IsRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hysteria2IsRunning — та же проверка в виде значения.
|
||||||
|
//
|
||||||
|
// Переменная нужна тестам: без неё каждая проверка, зависящая от состояния
|
||||||
|
// службы, доказуема только на живом systemd, то есть не доказуема в сборке
|
||||||
|
// вовсе. Продуктовый путь от этого не меняется — значение по умолчанию и есть
|
||||||
|
// прежняя реализация.
|
||||||
|
var hysteria2IsRunning = func() bool {
|
||||||
_, err := util.Exec("systemctl is-active --quiet hysteria-server")
|
_, err := util.Exec("systemctl is-active --quiet hysteria-server")
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-26
@@ -48,7 +48,10 @@ func resolvePublicEndpoint() (string, int, error) {
|
|||||||
}
|
}
|
||||||
port, err := strconv.Atoi(portRaw)
|
port, err := strconv.Atoi(portRaw)
|
||||||
if err != nil || port < 1 || port > 65535 {
|
if err != nil || port < 1 || port > 65535 {
|
||||||
return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid TCP port")
|
// Транспорт в формулировке не называется: публичный endpoint Hysteria —
|
||||||
|
// UDP/QUIC, и «TCP port» здесь закладывал в сообщение об ошибке
|
||||||
|
// заведомо ложную семантику.
|
||||||
|
return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid port")
|
||||||
}
|
}
|
||||||
return host, port, nil
|
return host, port, nil
|
||||||
}
|
}
|
||||||
@@ -68,67 +71,170 @@ func Hysteria2Auth(conPass string) (int64, string, error) {
|
|||||||
return 0, "", err
|
return 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ограничение количества устройств
|
// Строка без идентичности — повреждённые данные, а не пир.
|
||||||
onlineUsers, err := Hysteria2Online()
|
//
|
||||||
if err != nil {
|
// Проверка стоит здесь по той же причине, что и проверка maxDevices ниже:
|
||||||
logrus.WithError(err).Warn("hysteria2 online users unavailable; skip device-limit check")
|
// это путь КАЖДОГО подключения пира, и разыменование nil на нём означает
|
||||||
return *peer.Id, *peer.AuthId, nil
|
// панику в обработчике machine-auth, а не отказ одному клиенту.
|
||||||
|
if peer.Id == nil || peer.AuthId == nil || *peer.AuthId == "" {
|
||||||
|
logrus.Error("peer row has no usable identity; rejecting auth")
|
||||||
|
return 0, "", errors.New("peer identity unavailable")
|
||||||
}
|
}
|
||||||
device, exist := onlineUsers[*peer.AuthId]
|
|
||||||
if exist && *peer.MaxDevices <= device {
|
// Ограничение количества устройств — fail-closed.
|
||||||
|
//
|
||||||
|
// Раньше отказ Traffic Stats API обрабатывался так:
|
||||||
|
//
|
||||||
|
// onlineUsers, err := Hysteria2Online()
|
||||||
|
// if err != nil {
|
||||||
|
// logrus.WithError(err).Warn(...)
|
||||||
|
// return *peer.Id, *peer.AuthId, nil
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// То есть недоступность внутреннего 127.0.0.1 превращала объявленный в
|
||||||
|
// панели «Лимит устройств: 3» в безлимит, и узнать об этом оператор мог
|
||||||
|
// только по строке WARN в журнале, которую никто не читает. Ограничение,
|
||||||
|
// которое отключается само при первой же внутренней неполадке, не является
|
||||||
|
// ограничением.
|
||||||
|
//
|
||||||
|
// Вторая половина той же дыры была тише: общий Hysteria2Online отдавал
|
||||||
|
// пустую карту БЕЗ ошибки, когда systemd отвечал «служба неактивна», —
|
||||||
|
// а этот ответ не отличается от «спросить systemctl не удалось». Поэтому
|
||||||
|
// здесь берётся строгий путь: только фактический ответ Traffic Stats API.
|
||||||
|
//
|
||||||
|
// Направление отказа выбрано осознанно. Запрос авторизации приходит ОТ
|
||||||
|
// Hysteria, то есть в момент этой проверки Hysteria заведомо жива, а её
|
||||||
|
// Traffic Stats API слушает loopback внутри того же процесса. Его
|
||||||
|
// недоступность здесь — не штатное состояние, а аномалия, и пускать
|
||||||
|
// подключения без единственной проверки, которая ещё не выполнена, значит
|
||||||
|
// молча снять лимит со всех пиров сразу.
|
||||||
|
onlineUsers, err := hysteria2Online()
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).
|
||||||
|
WithField("peerId", *peer.Id).
|
||||||
|
Error("hysteria2 traffic stats api unavailable; device limit cannot be enforced, rejecting auth")
|
||||||
|
return 0, "", errors.New("device limit unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxDevices без значения — это не «безлимит», а неизвестная граница.
|
||||||
|
// Схема даёт колонке DEFAULT, форма требует min=1, импорт приводит <=0 к 3,
|
||||||
|
// поэтому nil здесь означать может только повреждённую строку — и на пути
|
||||||
|
// принятия решения о доступе она обязана вести к отказу, а не к пропуску.
|
||||||
|
if peer.MaxDevices == nil || *peer.MaxDevices < 1 {
|
||||||
|
logrus.WithField("peerId", *peer.Id).
|
||||||
|
Error("peer has no usable maxDevices; rejecting auth")
|
||||||
|
return 0, "", errors.New("device limit unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if device, exist := onlineUsers[*peer.AuthId]; exist && *peer.MaxDevices <= device {
|
||||||
return 0, "", errors.New("device limited")
|
return 0, "", errors.New("device limited")
|
||||||
}
|
}
|
||||||
|
|
||||||
return *peer.Id, *peer.AuthId, nil
|
return *peer.Id, *peer.AuthId, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hysteria2Online — картина подключений ДЛЯ ОТОБРАЖЕНИЯ.
|
||||||
|
//
|
||||||
|
// Терпима к недоступности Hysteria намеренно: дашборду и списку пиров нечего
|
||||||
|
// показать, если Traffic Stats API молчит, и пустая картина — честный ответ на
|
||||||
|
// вопрос «кто сейчас на связи», когда служба остановлена.
|
||||||
|
//
|
||||||
|
// Этой терпимости НЕТ в решениях о доступе. Ярлык `!hysteria2IsRunning() ->
|
||||||
|
// пусто, ошибки нет` раньше стоял на общем пути, и через него ограничение
|
||||||
|
// устройств обходилось молча: systemctl отвечает через util.Exec, который не
|
||||||
|
// отличает «служба неактивна» от «спросить не удалось», а «никого нет онлайн»
|
||||||
|
// для проверки лимита означает «пускать всех». Отказа при этом не возникало
|
||||||
|
// вовсе — не было даже записи в журнале.
|
||||||
func Hysteria2Online() (map[string]int64, error) {
|
func Hysteria2Online() (map[string]int64, error) {
|
||||||
if !Hysteria2IsRunning() {
|
if !hysteria2IsRunning() {
|
||||||
return map[string]int64{}, nil
|
return map[string]int64{}, nil
|
||||||
}
|
}
|
||||||
|
return hysteria2Online()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hysteria2Online — фактический ответ Traffic Stats API, без ярлыков.
|
||||||
|
//
|
||||||
|
// Недоступность здесь остаётся ошибкой: вызывающий обязан решить, что она для
|
||||||
|
// него значит, и не может получить пустую карту вместо отказа.
|
||||||
|
func hysteria2Online() (map[string]int64, error) {
|
||||||
apiPort, err := GetHysteria2ApiPort()
|
apiPort, err := GetHysteria2ApiPort()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("get hysteria2 apiPort err")
|
return nil, errors.New("get hysteria2 apiPort err")
|
||||||
}
|
}
|
||||||
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
secret, err := hysteria2TrafficSecret()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
onlineUsers, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(*trafficSecretConfig.Value)
|
return proxy.NewHysteria2Api(apiPort).OnlineUsers(secret)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return onlineUsers, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Hysteria2Kick(ids []int64, kickUtilTime int64) error {
|
// hysteria2TrafficSecret отдаёт секрет Traffic Stats API.
|
||||||
if !Hysteria2IsRunning() {
|
//
|
||||||
return errors.New("hysteria2 is not running")
|
// Отсутствующее значение ключа — отказ, а не пустая строка. Раньше по этому
|
||||||
|
// пути стояло `*config.Value` без проверки: строка в таблице `config` без
|
||||||
|
// значения роняла бы админку паникой на разыменовании nil прямо в обработчике
|
||||||
|
// machine-auth, то есть на пути каждого подключения пира.
|
||||||
|
func hysteria2TrafficSecret() (string, error) {
|
||||||
|
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
if err := dao.UpdatePeer(ids, map[string]interface{}{"banned_until": kickUtilTime}); err != nil {
|
if trafficSecretConfig.Value == nil || *trafficSecretConfig.Value == "" {
|
||||||
return err
|
return "", errors.New("hysteria2 traffic stats secret is not configured")
|
||||||
|
}
|
||||||
|
return *trafficSecretConfig.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisconnectPeers завершает активные Hysteria-сессии пиров и НИЧЕГО не пишет в
|
||||||
|
// базу.
|
||||||
|
//
|
||||||
|
// Разрыв сессии и запись состояния разделены сознательно. Прежний
|
||||||
|
// Hysteria2Kick делал и то и другое: вместе с обращением к `/kick` он
|
||||||
|
// проставлял `banned_until`. Из-за этого им нельзя было воспользоваться для
|
||||||
|
// отключения пира — операция `disabled=1` записала бы заодно временную
|
||||||
|
// блокировку, а это другой механизм с другим сроком жизни и другим способом
|
||||||
|
// снятия. Единственный вызывающий (KickPeer) при этом писал `banned_until` ещё
|
||||||
|
// и сам, то есть одно и то же значение уезжало в базу дважды.
|
||||||
|
//
|
||||||
|
// Здесь остаётся ровно официальный Traffic Stats `/kick` и ничего больше.
|
||||||
|
//
|
||||||
|
// Состояние службы по systemd НЕ проверяется. Раньше путь начинался с
|
||||||
|
// `!Hysteria2IsRunning() -> отказ`, и это давало худшее из двух: ответ systemd
|
||||||
|
// не отличает «служба неактивна» от «спросить не удалось» (см.
|
||||||
|
// Hysteria2IsRunning), поэтому сбой самого systemctl превращался в отказ
|
||||||
|
// операции при живой Hysteria, а обратная ошибка молча пропускала бы разрыв.
|
||||||
|
// Обращение к `/kick` отвечает на нужный вопрос напрямую и без посредника.
|
||||||
|
func DisconnectPeers(ids []int64) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
peers, err := dao.ListPeer("id in ?", ids)
|
peers, err := dao.ListPeer("id in ?", ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var keys []string
|
|
||||||
|
keys := make([]string, 0, len(peers))
|
||||||
for _, item := range peers {
|
for _, item := range peers {
|
||||||
|
if item.AuthId == nil || *item.AuthId == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
keys = append(keys, *item.AuthId)
|
keys = append(keys, *item.AuthId)
|
||||||
}
|
}
|
||||||
|
// Пир без authId Hysteria не знает: рвать нечего, и это не отказ.
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
apiPort, err := GetHysteria2ApiPort()
|
apiPort, err := GetHysteria2ApiPort()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("get hysteria2 apiPort err")
|
return errors.New("get hysteria2 apiPort err")
|
||||||
}
|
}
|
||||||
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
secret, err := hysteria2TrafficSecret()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = proxy.NewHysteria2Api(apiPort).KickUsers(keys, *trafficSecretConfig.Value); err != nil {
|
return proxy.NewHysteria2Api(apiPort).KickUsers(keys, secret)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Hysteria2Url(accountId int64) (string, error) {
|
func Hysteria2Url(accountId int64) (string, error) {
|
||||||
|
|||||||
+64
-2
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"hy2xs-admin/dao"
|
"hy2xs-admin/dao"
|
||||||
"hy2xs-admin/model/bo"
|
"hy2xs-admin/model/bo"
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
@@ -160,7 +162,52 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
|||||||
if peerDto.Remark != nil {
|
if peerDto.Remark != nil {
|
||||||
updates["remark"] = *peerDto.Remark
|
updates["remark"] = *peerDto.Remark
|
||||||
}
|
}
|
||||||
return dao.UpdatePeer([]int64{id}, updates)
|
if err := dao.UpdatePeer([]int64{id}, updates); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отключение пира — это ОБЕ половины официального контракта Hysteria.
|
||||||
|
//
|
||||||
|
// Запись `disabled=1` закрывает только будущие обращения к HTTP-auth: её
|
||||||
|
// видит условие выборки в Hysteria2Auth. Уже установленная QUIC-сессия
|
||||||
|
// живёт своей жизнью и сама по себе не разрывается — то есть после
|
||||||
|
// «Отключить» пир продолжал пользоваться доступом сколько угодно долго,
|
||||||
|
// пока не переподключался по своей воле. Панель при этом показывала его
|
||||||
|
// отключённым.
|
||||||
|
//
|
||||||
|
// Вторую половину даёт Traffic Stats `/kick`. Официальная документация
|
||||||
|
// описывает их именно как пару: `/kick` завершает сессию, но клиент
|
||||||
|
// немедленно переподключается, поэтому одновременно требуется блокировка в
|
||||||
|
// auth backend. По отдельности не работает ни одна.
|
||||||
|
//
|
||||||
|
// Порядок обязателен и обратному не подлежит: сначала долговременная
|
||||||
|
// запись, потом разрыв. При обратном порядке клиент успевает
|
||||||
|
// переподключиться в окне между `/kick` и записью — и остаётся на связи с
|
||||||
|
// формально отключённым пиром.
|
||||||
|
//
|
||||||
|
// Условие проверяет ЗАПРОШЕННОЕ состояние, а не переход из включённого.
|
||||||
|
// Так операция остаётся повторяемой: если разрыв не удался, оператор
|
||||||
|
// повторяет «Отключить» и получает вторую попытку, вместо того чтобы
|
||||||
|
// включать пира ради возможности отключить его снова.
|
||||||
|
if peerDto.Disabled != nil && *peerDto.Disabled == 1 {
|
||||||
|
return disconnectAfterRevoke(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// disconnectAfterRevoke рвёт сессии пира после уже применённой записи.
|
||||||
|
//
|
||||||
|
// Отказ НЕ откатывает состояние: безопасная его половина достигнута, и
|
||||||
|
// возвращать пиру полный доступ из-за неудачи второго шага нельзя. Вызывающему
|
||||||
|
// возвращается частичный результат отдельным кодом — см. PeerDisconnectError.
|
||||||
|
func disconnectAfterRevoke(id int64) error {
|
||||||
|
if err := DisconnectPeers([]int64{id}); err != nil {
|
||||||
|
logrus.WithError(err).
|
||||||
|
WithField("peerId", id).
|
||||||
|
Error("peer access revoked in database, but hysteria2 session disconnect failed")
|
||||||
|
return PeerDisconnectError()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано
|
// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано
|
||||||
@@ -246,11 +293,26 @@ func ReleaseKickPeer(id int64) error {
|
|||||||
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0})
|
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KickPeer — временная блокировка: пир не пускается до bannedUntil, а его
|
||||||
|
// текущая сессия завершается.
|
||||||
|
//
|
||||||
|
// Механизм независим от `disabled`. Это два разных решения оператора с разным
|
||||||
|
// сроком жизни: `banned_until` истекает сам, `disabled` снимается только
|
||||||
|
// руками. Поэтому ReleaseKickPeer не трогает `disabled`, а включение пира не
|
||||||
|
// сбрасывает `banned_until`.
|
||||||
|
//
|
||||||
|
// Что было. `banned_until` записывался здесь, а затем ВТОРОЙ РАЗ внутри
|
||||||
|
// Hysteria2Kick — то же значение двумя операторами подряд. Хуже дублирования
|
||||||
|
// был порядок отказа: Hysteria2Kick начинался с проверки состояния службы и
|
||||||
|
// возвращал ошибку, не сделав ничего, — но первая запись к этому моменту уже
|
||||||
|
// применилась. Операция отвечала чистым отказом, находясь при этом в
|
||||||
|
// применённом состоянии, и оператор видел «не сработало» у сработавшей
|
||||||
|
// блокировки.
|
||||||
func KickPeer(id int64, bannedUntil int64) error {
|
func KickPeer(id int64, bannedUntil int64) error {
|
||||||
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil {
|
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return Hysteria2Kick([]int64{id}, bannedUntil)
|
return disconnectAfterRevoke(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
|
func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
|
||||||
|
|||||||
@@ -0,0 +1,620 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/dao"
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
|
"hy2xs-admin/model/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО HTTP, а не против подменённого
|
||||||
|
// клиента.
|
||||||
|
//
|
||||||
|
// Контракт, который здесь доказывается, — официальный Traffic Stats API
|
||||||
|
// Hysteria: метод, путь, заголовок Authorization и JSON-массив идентификаторов
|
||||||
|
// в теле. Подменённый на уровне Go клиент доказал бы только то, что вызвана
|
||||||
|
// нужная функция, и молча пережил бы, например, потерю заголовка. Слой proxy
|
||||||
|
// ходит на 127.0.0.1:<порт из конфига>, а httptest слушает ровно там, поэтому
|
||||||
|
// настоящий запрос ставится без единой правки продуктового кода.
|
||||||
|
|
||||||
|
const testTrafficStatsSecret = "test-traffic-stats-secret"
|
||||||
|
|
||||||
|
// trafficStatsStub — Traffic Stats API Hysteria в объёме, который использует
|
||||||
|
// админка.
|
||||||
|
type trafficStatsStub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// online отдаётся на GET /online.
|
||||||
|
online map[string]int64
|
||||||
|
// onlineStatus != 0 подменяет ответ /online кодом отказа.
|
||||||
|
onlineStatus int
|
||||||
|
// kickStatus != 0 подменяет ответ /kick кодом отказа.
|
||||||
|
kickStatus int
|
||||||
|
|
||||||
|
// Наблюдения.
|
||||||
|
onlineCalls int
|
||||||
|
kickCalls int
|
||||||
|
kickedKeys [][]string
|
||||||
|
kickAuth []string
|
||||||
|
// disabledAtKick — состояние колонки disabled на момент прихода /kick.
|
||||||
|
// Именно этим доказывается порядок «сначала запись, потом разрыв»:
|
||||||
|
// проверить его после операции нельзя, там оба шага уже завершены.
|
||||||
|
disabledAtKick []map[string]int64
|
||||||
|
bannedAtKick []map[string]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *trafficStatsStub) snapshot(t *testing.T) (map[string]int64, map[string]int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
disabled := map[string]int64{}
|
||||||
|
banned := map[string]int64{}
|
||||||
|
peers, err := dao.ListPeer("1=1")
|
||||||
|
if err != nil {
|
||||||
|
return disabled, banned
|
||||||
|
}
|
||||||
|
for _, peer := range peers {
|
||||||
|
if peer.Name == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if peer.Disabled != nil {
|
||||||
|
disabled[*peer.Name] = *peer.Disabled
|
||||||
|
}
|
||||||
|
if peer.BannedUntil != nil {
|
||||||
|
banned[*peer.Name] = *peer.BannedUntil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return disabled, banned
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTrafficStats поднимает Traffic Stats API на 127.0.0.1 и указывает на
|
||||||
|
// него конфиг Hysteria, который читает админка.
|
||||||
|
func startTrafficStats(t *testing.T, stub *trafficStatsStub) *trafficStatsStub {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if stub == nil {
|
||||||
|
stub = &trafficStatsStub{}
|
||||||
|
}
|
||||||
|
if stub.online == nil {
|
||||||
|
stub.online = map[string]int64{}
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stub.mu.Lock()
|
||||||
|
defer stub.mu.Unlock()
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/online":
|
||||||
|
stub.onlineCalls++
|
||||||
|
if stub.onlineStatus != 0 {
|
||||||
|
w.WriteHeader(stub.onlineStatus)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(stub.online)
|
||||||
|
case "/kick":
|
||||||
|
stub.kickCalls++
|
||||||
|
stub.kickAuth = append(stub.kickAuth, r.Header.Get("Authorization"))
|
||||||
|
|
||||||
|
disabled, banned := stub.snapshot(t)
|
||||||
|
stub.disabledAtKick = append(stub.disabledAtKick, disabled)
|
||||||
|
stub.bannedAtKick = append(stub.bannedAtKick, banned)
|
||||||
|
|
||||||
|
var keys []string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stub.kickedKeys = append(stub.kickedKeys, keys)
|
||||||
|
|
||||||
|
if stub.kickStatus != 0 {
|
||||||
|
w.WriteHeader(stub.kickStatus)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
pointHysteriaConfigAt(t, server.URL)
|
||||||
|
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
|
||||||
|
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||||||
|
}
|
||||||
|
return stub
|
||||||
|
}
|
||||||
|
|
||||||
|
// pointHysteriaConfigAt пишет конфиг Hysteria, в котором Traffic Stats API
|
||||||
|
// слушает порт переданного адреса.
|
||||||
|
func pointHysteriaConfigAt(t *testing.T, serverURL string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
host := serverURL
|
||||||
|
for _, prefix := range []string{"http://", "https://"} {
|
||||||
|
if len(host) > len(prefix) && host[:len(prefix)] == prefix {
|
||||||
|
host = host[len(prefix):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, port, err := net.SplitHostPort(host)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", serverURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
content := fmt.Sprintf("listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:%s\"\n secret: %q\n",
|
||||||
|
port, testTrafficStatsSecret)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
previous := constant.Hysteria2ConfigPath
|
||||||
|
constant.Hysteria2ConfigPath = path
|
||||||
|
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||||||
|
}
|
||||||
|
|
||||||
|
// pointHysteriaConfigAtDeadPort указывает конфиг на порт, который никто не
|
||||||
|
// слушает: так воспроизводится недоступность Traffic Stats API.
|
||||||
|
func pointHysteriaConfigAtDeadPort(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось занять порт: %v", err)
|
||||||
|
}
|
||||||
|
addr := listener.Addr().String()
|
||||||
|
// Порт освобождается сразу: адрес известен, слушателя на нём нет.
|
||||||
|
if err := listener.Close(); err != nil {
|
||||||
|
t.Fatalf("не удалось освободить порт: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pointHysteriaConfigAt(t, "http://"+addr)
|
||||||
|
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
|
||||||
|
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withHysteriaRunning подменяет ответ systemd о состоянии службы.
|
||||||
|
func withHysteriaRunning(t *testing.T, running bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
previous := hysteria2IsRunning
|
||||||
|
hysteria2IsRunning = func() bool { return running }
|
||||||
|
t.Cleanup(func() { hysteria2IsRunning = previous })
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerByName(t *testing.T, name string) (disabled int64, bannedUntil int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
peer, ok := snapshotPeers(t)[name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("пир %q не найден", name)
|
||||||
|
}
|
||||||
|
if peer.Disabled != nil {
|
||||||
|
disabled = *peer.Disabled
|
||||||
|
}
|
||||||
|
if peer.BannedUntil != nil {
|
||||||
|
bannedUntil = *peer.BannedUntil
|
||||||
|
}
|
||||||
|
return disabled, bannedUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireDisconnectError(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("частичный результат не сообщён: ошибки нет вовсе")
|
||||||
|
}
|
||||||
|
var peerErr *PeerError
|
||||||
|
if !errors.As(err, &peerErr) {
|
||||||
|
t.Fatalf("отказ не несёт машиночитаемого кода: %v", err)
|
||||||
|
}
|
||||||
|
if peerErr.Code != constant.ErrCodePeerDisconnectFailed {
|
||||||
|
t.Fatalf("ожидался код %q, получен %q", constant.ErrCodePeerDisconnectFailed, peerErr.Code)
|
||||||
|
}
|
||||||
|
if peerErr.Field != "" {
|
||||||
|
t.Errorf("частичный результат отнесён к полю формы %q, хотя относится к операции целиком", peerErr.Field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DisconnectPeers ---------------------------------------------------------
|
||||||
|
|
||||||
|
func TestDisconnectPeersSendsOfficialKickContract(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startTrafficStats(t, nil)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if err := DisconnectPeers([]int64{id}); err != nil {
|
||||||
|
t.Fatalf("разрыв сессии отказал: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stub.kickCalls != 1 {
|
||||||
|
t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls)
|
||||||
|
}
|
||||||
|
if got := stub.kickedKeys[0]; len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||||
|
t.Fatalf("в /kick уехали не те идентификаторы: %v", got)
|
||||||
|
}
|
||||||
|
if stub.kickAuth[0] != testTrafficStatsSecret {
|
||||||
|
t.Fatalf("секрет Traffic Stats API не передан в Authorization: %q", stub.kickAuth[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разрыв сессии НИЧЕГО не пишет в базу. Прежний Hysteria2Kick заодно
|
||||||
|
// проставлял banned_until, из-за чего им нельзя было воспользоваться для
|
||||||
|
// операции «Отключить»: она записала бы временную блокировку — другой механизм
|
||||||
|
// с другим сроком жизни.
|
||||||
|
func TestDisconnectPeersDoesNotTouchPeerState(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, nil)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if err := DisconnectPeers([]int64{id}); err != nil {
|
||||||
|
t.Fatalf("разрыв сессии отказал: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled, bannedUntil := peerByName(t, "alpha1")
|
||||||
|
if disabled != 0 {
|
||||||
|
t.Errorf("разрыв сессии изменил disabled: %d", disabled)
|
||||||
|
}
|
||||||
|
if bannedUntil != 0 {
|
||||||
|
t.Errorf("разрыв сессии изменил banned_until: %d", bannedUntil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisconnectPeersIsNoopWithoutTargets(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startTrafficStats(t, nil)
|
||||||
|
|
||||||
|
if err := DisconnectPeers(nil); err != nil {
|
||||||
|
t.Fatalf("пустой список признан отказом: %v", err)
|
||||||
|
}
|
||||||
|
// Пир без authId Hysteria не знает: рвать нечего.
|
||||||
|
id := seedPeer(t, "alpha1", "")
|
||||||
|
if err := DisconnectPeers([]int64{id}); err != nil {
|
||||||
|
t.Fatalf("пир без authId признан отказом: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stub.kickCalls != 0 {
|
||||||
|
t.Fatalf("сделан вызов /kick без единой цели: %d", stub.kickCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisconnectPeersReportsApiFailure(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if err := DisconnectPeers([]int64{id}); err == nil {
|
||||||
|
t.Fatal("отказ Traffic Stats API не сообщён")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Состояние службы по systemd на этом пути не спрашивается вовсе: ответ
|
||||||
|
// systemctl не отличает «служба неактивна» от «спросить не удалось», и на
|
||||||
|
// прежнем пути его сбой отказывал операции при живой Hysteria.
|
||||||
|
func TestDisconnectPeersIgnoresSystemdOpinion(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startTrafficStats(t, nil)
|
||||||
|
withHysteriaRunning(t, false)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if err := DisconnectPeers([]int64{id}); err != nil {
|
||||||
|
t.Fatalf("разрыв сессии отказал из-за мнения systemd: %v", err)
|
||||||
|
}
|
||||||
|
if stub.kickCalls != 1 {
|
||||||
|
t.Fatalf("разрыв сессии пропущен из-за мнения systemd: вызовов /kick %d", stub.kickCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Отключение пира ---------------------------------------------------------
|
||||||
|
|
||||||
|
func TestUpdatePeerDisableWritesThenDisconnects(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startTrafficStats(t, nil)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
one := int64(1)
|
||||||
|
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
|
||||||
|
t.Fatalf("отключение пира отказало: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled, bannedUntil := peerByName(t, "alpha1")
|
||||||
|
if disabled != 1 {
|
||||||
|
t.Fatalf("пир не отключён: disabled=%d", disabled)
|
||||||
|
}
|
||||||
|
// Временная блокировка — независимый механизм, отключение её не ставит.
|
||||||
|
if bannedUntil != 0 {
|
||||||
|
t.Errorf("отключение проставило banned_until=%d", bannedUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stub.kickCalls != 1 {
|
||||||
|
t.Fatalf("активная сессия не разорвана: вызовов /kick %d", stub.kickCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порядок: к моменту прихода /kick запрет новых подключений уже в базе.
|
||||||
|
// При обратном порядке клиент успевает переподключиться в окне между
|
||||||
|
// разрывом и записью и остаётся на связи с формально отключённым пиром.
|
||||||
|
if got := stub.disabledAtKick[0]["alpha1"]; got != 1 {
|
||||||
|
t.Fatalf("/kick пришёл раньше записи disabled: на момент разрыва disabled=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Неудача разрыва НЕ откатывает запрет новых подключений: безопасная половина
|
||||||
|
// операции достигнута, и возвращать пиру полный доступ из-за второго шага
|
||||||
|
// нельзя.
|
||||||
|
func TestUpdatePeerDisableKeepsStateWhenDisconnectFails(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
one := int64(1)
|
||||||
|
err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one})
|
||||||
|
requireDisconnectError(t, err)
|
||||||
|
|
||||||
|
if disabled, _ := peerByName(t, "alpha1"); disabled != 1 {
|
||||||
|
t.Fatalf("запрет новых подключений откачен после неудачного разрыва: disabled=%d", disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Включение пира не рвёт сессий и не трогает временную блокировку: это
|
||||||
|
// независимые механизмы.
|
||||||
|
func TestUpdatePeerEnableDoesNotDisconnect(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startTrafficStats(t, nil)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if err := KickPeer(id, 1_700_000_000_000); err != nil {
|
||||||
|
t.Fatalf("временная блокировка отказала: %v", err)
|
||||||
|
}
|
||||||
|
kicksAfterBan := stub.kickCalls
|
||||||
|
|
||||||
|
zero := int64(0)
|
||||||
|
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &zero}); err != nil {
|
||||||
|
t.Fatalf("включение пира отказало: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stub.kickCalls != kicksAfterBan {
|
||||||
|
t.Fatalf("включение пира вызвало /kick: %d -> %d", kicksAfterBan, stub.kickCalls)
|
||||||
|
}
|
||||||
|
disabled, bannedUntil := peerByName(t, "alpha1")
|
||||||
|
if disabled != 0 {
|
||||||
|
t.Fatalf("пир не включён: disabled=%d", disabled)
|
||||||
|
}
|
||||||
|
if bannedUntil != 1_700_000_000_000 {
|
||||||
|
t.Errorf("включение сбросило независимую временную блокировку: banned_until=%d", bannedUntil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правка постороннего поля не является отзывом доступа и сессий не рвёт.
|
||||||
|
func TestUpdatePeerUnrelatedFieldDoesNotDisconnect(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startTrafficStats(t, nil)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if err := UpdatePeer(id, dto.PeerUpdateDto{Remark: strPtr("ноутбук")}); err != nil {
|
||||||
|
t.Fatalf("правка комментария отказала: %v", err)
|
||||||
|
}
|
||||||
|
if stub.kickCalls != 0 {
|
||||||
|
t.Fatalf("правка комментария разорвала сессии: вызовов /kick %d", stub.kickCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Операция повторяема: условие смотрит на ЗАПРОШЕННОЕ состояние, а не на
|
||||||
|
// переход из включённого. Иначе после неудачного разрыва оператору пришлось бы
|
||||||
|
// включить пира, чтобы получить право отключить его снова.
|
||||||
|
func TestUpdatePeerDisableIsRetryable(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
one := int64(1)
|
||||||
|
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}))
|
||||||
|
|
||||||
|
stub.mu.Lock()
|
||||||
|
stub.kickStatus = 0
|
||||||
|
stub.mu.Unlock()
|
||||||
|
|
||||||
|
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
|
||||||
|
t.Fatalf("повторное отключение отказало: %v", err)
|
||||||
|
}
|
||||||
|
if stub.kickCalls != 2 {
|
||||||
|
t.Fatalf("повторная попытка не дошла до /kick: вызовов %d", stub.kickCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Временная блокировка ----------------------------------------------------
|
||||||
|
|
||||||
|
func TestKickPeerWritesBanOnceAndDisconnects(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startTrafficStats(t, nil)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
const bannedUntil = int64(1_700_000_000_000)
|
||||||
|
if err := KickPeer(id, bannedUntil); err != nil {
|
||||||
|
t.Fatalf("временная блокировка отказала: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled, banned := peerByName(t, "alpha1")
|
||||||
|
if banned != bannedUntil {
|
||||||
|
t.Fatalf("banned_until не записан: %d", banned)
|
||||||
|
}
|
||||||
|
// Временная блокировка и отключение независимы.
|
||||||
|
if disabled != 0 {
|
||||||
|
t.Errorf("временная блокировка изменила disabled: %d", disabled)
|
||||||
|
}
|
||||||
|
if stub.kickCalls != 1 {
|
||||||
|
t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls)
|
||||||
|
}
|
||||||
|
if got := stub.bannedAtKick[0]["alpha1"]; got != bannedUntil {
|
||||||
|
t.Fatalf("/kick пришёл раньше записи banned_until: на момент разрыва %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регрессия: прежний путь начинался с проверки состояния службы и возвращал
|
||||||
|
// чистый отказ, НЕ СДЕЛАВ ничего, — при том что запись banned_until к этому
|
||||||
|
// моменту уже применилась. Оператор видел «не сработало» у сработавшей
|
||||||
|
// блокировки.
|
||||||
|
func TestKickPeerReportsPartialResultWhenDisconnectFails(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
const bannedUntil = int64(1_700_000_000_000)
|
||||||
|
requireDisconnectError(t, KickPeer(id, bannedUntil))
|
||||||
|
|
||||||
|
if _, banned := peerByName(t, "alpha1"); banned != bannedUntil {
|
||||||
|
t.Fatalf("блокировка не применена, хотя сообщён частичный результат: banned_until=%d", banned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseKickPeerLeavesDisabledAlone(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, nil)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
one := int64(1)
|
||||||
|
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
|
||||||
|
t.Fatalf("отключение пира отказало: %v", err)
|
||||||
|
}
|
||||||
|
if err := KickPeer(id, 1_700_000_000_000); err != nil {
|
||||||
|
t.Fatalf("временная блокировка отказала: %v", err)
|
||||||
|
}
|
||||||
|
if err := ReleaseKickPeer(id); err != nil {
|
||||||
|
t.Fatalf("снятие блокировки отказало: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled, banned := peerByName(t, "alpha1")
|
||||||
|
if banned != 0 {
|
||||||
|
t.Fatalf("banned_until не сброшен: %d", banned)
|
||||||
|
}
|
||||||
|
if disabled != 1 {
|
||||||
|
t.Fatalf("снятие временной блокировки включило отключённого пира: disabled=%d", disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ограничение устройств (fail-closed) -------------------------------------
|
||||||
|
|
||||||
|
func TestHysteria2AuthRejectsWhenTrafficStatsRefuses(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError})
|
||||||
|
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||||
|
t.Fatal("отказ Traffic Stats API впустил подключение без проверки лимита устройств")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHysteria2AuthRejectsWhenTrafficStatsUnreachable(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
pointHysteriaConfigAtDeadPort(t)
|
||||||
|
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||||
|
t.Fatal("недоступность Traffic Stats API впустила подключение без проверки лимита устройств")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регрессия AUTH-02, вторая половина дыры и самая тихая: общий Hysteria2Online
|
||||||
|
// отдавал ПУСТУЮ КАРТУ БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», —
|
||||||
|
// а этот ответ не отличается от «спросить systemctl не удалось». Лимит
|
||||||
|
// устройств переставал проверяться, и в журнале не появлялось ни строки.
|
||||||
|
func TestHysteria2AuthEnforcesLimitWhenSystemdSaysStopped(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
|
||||||
|
withHysteriaRunning(t, false)
|
||||||
|
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||||
|
t.Fatal("мнение systemd отключило проверку лимита устройств")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHysteria2AuthAcceptsBelowDeviceLimit(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
gotID, gotAuth, err := Hysteria2Auth("alpha1-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("подключение в пределах лимита отклонено: %v", err)
|
||||||
|
}
|
||||||
|
if gotID != id || gotAuth != "alpha-auth-id" {
|
||||||
|
t.Fatalf("авторизация вернула не того пира: id=%d authId=%q", gotID, gotAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHysteria2AuthRejectsAtDeviceLimit(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
// seedPeer создаёт пира с maxDevices = 3.
|
||||||
|
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
|
||||||
|
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||||
|
t.Fatal("подключение сверх лимита устройств принято")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повреждённая граница — это не «безлимит». Строка без пригодного maxDevices
|
||||||
|
// на пути принятия решения о доступе обязана вести к отказу.
|
||||||
|
func TestHysteria2AuthRejectsUnusableDeviceLimit(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, nil)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"max_devices": 0}); err != nil {
|
||||||
|
t.Fatalf("не удалось подготовить повреждённую строку: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||||
|
t.Fatal("пир без пригодного лимита устройств принят")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Путь машинной авторизации проходят ВСЕ подключения пиров, поэтому
|
||||||
|
// разыменование nil на нём означает панику в обработчике, а не отказ одному
|
||||||
|
// клиенту.
|
||||||
|
func TestHysteria2AuthRejectsPeerWithoutIdentity(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, nil)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"auth_id": ""}); err != nil {
|
||||||
|
t.Fatalf("не удалось подготовить повреждённую строку: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
t.Fatalf("повреждённая строка уронила обработчик авторизации: %v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||||
|
t.Fatal("пир без идентичности принят")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение остаётся терпимым: пустая картина подключений — честный ответ на
|
||||||
|
// вопрос «кто сейчас на связи», когда служба остановлена. Строгость нужна
|
||||||
|
// только там, где принимается решение о доступе.
|
||||||
|
func TestHysteria2OnlineStaysTolerantForDisplay(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
pointHysteriaConfigAtDeadPort(t)
|
||||||
|
withHysteriaRunning(t, false)
|
||||||
|
|
||||||
|
online, err := Hysteria2Online()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("отображение подключений стало отказывать: %v", err)
|
||||||
|
}
|
||||||
|
if len(online) != 0 {
|
||||||
|
t.Fatalf("ожидалась пустая картина подключений, получено %v", online)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,11 @@ func TestCreatePeerRefusesReservedName(t *testing.T) {
|
|||||||
// оператор не смог бы ни отключить bootstrap-пира, ни ограничить ему квоту.
|
// оператор не смог бы ни отключить bootstrap-пира, ни ограничить ему квоту.
|
||||||
func TestUpdatePeerAllowsNonIdentityChangesOnBootstrapPeer(t *testing.T) {
|
func TestUpdatePeerAllowsNonIdentityChangesOnBootstrapPeer(t *testing.T) {
|
||||||
newTestDB(t)
|
newTestDB(t)
|
||||||
|
// Отключение пира — это ещё и разрыв его активных сессий, поэтому Traffic
|
||||||
|
// Stats API обязан отвечать. Без него операция завершилась бы частичным
|
||||||
|
// результатом (запись применена, сессия не разорвана), и тест доказывал бы
|
||||||
|
// не то, ради чего написан.
|
||||||
|
startTrafficStats(t, nil)
|
||||||
id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
|
id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
|
||||||
|
|
||||||
disabled := int64(1)
|
disabled := int64(1)
|
||||||
|
|||||||
@@ -54,6 +54,27 @@ var ErrPeerNameReserved = &PeerError{
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PeerDisconnectError — долговременное состояние применено, активную сессию
|
||||||
|
// завершить не удалось.
|
||||||
|
//
|
||||||
|
// Причина отказа `/kick` в сообщение НЕ попадает. Она приходит из слоя proxy в
|
||||||
|
// виде транспортного текста («http connection error») и оператору не говорит
|
||||||
|
// ничего, а диагностическая ценность у неё есть только в журнале, куда она и
|
||||||
|
// пишется вызывающим. Оператору нужно другое: что именно уже сделано и что
|
||||||
|
// осталось сделать.
|
||||||
|
//
|
||||||
|
// Отказ относится к операции целиком, а не к полю формы: поля, которое можно
|
||||||
|
// было бы исправить, здесь нет.
|
||||||
|
func PeerDisconnectError() *PeerError {
|
||||||
|
return &PeerError{
|
||||||
|
Code: constant.ErrCodePeerDisconnectFailed,
|
||||||
|
Message: "новые подключения пира запрещены, но завершить его активные " +
|
||||||
|
"сессии не удалось: Traffic Stats API Hysteria недоступен. " +
|
||||||
|
"Уже установленное соединение может продолжать работать до " +
|
||||||
|
"переподключения клиента",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PeerNameTakenError — имя уже занято другим пиром.
|
// PeerNameTakenError — имя уже занято другим пиром.
|
||||||
func PeerNameTakenError(name string) *PeerError {
|
func PeerNameTakenError(name string) *PeerError {
|
||||||
return &PeerError{
|
return &PeerError{
|
||||||
|
|||||||
@@ -269,6 +269,15 @@ if fl.(*validate).fldIsPointer && getValue(field) != nil {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Разбор кода после этих исправлений
|
||||||
|
|
||||||
|
Проверка внесённых здесь исправлений по дереву на коммите `c0a43ae9` нашла
|
||||||
|
дефекты, которых хостовой прогон `rc1` не показывал, — включая два отдельных
|
||||||
|
пути, по которым молча снималось ограничение устройств, и отключение пира, не
|
||||||
|
разрывавшее его активную сессию. Они перечислены в
|
||||||
|
[2026-09-01-v1.0.0-rc2-preflight-findings.md](2026-09-01-v1.0.0-rc2-preflight-findings.md)
|
||||||
|
и закрыты до сборки `rc2`.
|
||||||
|
|
||||||
## Что осталось сделать до финального v1.0.0
|
## Что осталось сделать до финального v1.0.0
|
||||||
|
|
||||||
1. пересобрать `rc2` и повторить build/security acceptance;
|
1. пересобрать `rc2` и повторить build/security acceptance;
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# Разбор кода перед сборкой `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. проверить, что отмена любого подтверждения не оставляет ошибок в консоли
|
||||||
|
браузера.
|
||||||
@@ -31,3 +31,13 @@
|
|||||||
| Прогон | Дефекты |
|
| Прогон | Дефекты |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
|
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
|
||||||
|
|
||||||
|
## Разборы кода между прогонами
|
||||||
|
|
||||||
|
Отдельно от отчётов о прогонах: дефекты, найденные чтением дерева и сверкой с
|
||||||
|
официальной документацией, а не наблюдением на хосте. Провенанс у них другой, и
|
||||||
|
приписывать их хостовому прогону нельзя — он их не показывал.
|
||||||
|
|
||||||
|
| Дата | Основание | Находки |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-09-01 | коммит `c0a43ae9`, сверка с Hysteria 2 и Element Plus | [UX-06…UX-10, LOG-01…LOG-05, AUTH-01/02, CORE-01/02, TYPE-01](2026-09-01-v1.0.0-rc2-preflight-findings.md) |
|
||||||
|
|||||||
@@ -113,9 +113,21 @@ Hysteria обращается к машинному endpoint'у как
|
|||||||
пира. Поэтому в журнале админки пишется **путь**, а не `RequestURI`:
|
пира. Поэтому в журнале админки пишется **путь**, а не `RequestURI`:
|
||||||
|
|
||||||
```json
|
```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 оседал открытым
|
Пока логировался `RequestURI`, действующий machine token оседал открытым
|
||||||
текстом в `/var/log/hy2xs/hy2xs-admin.log`. Этот файл отдаётся оператору через
|
текстом в `/var/log/hy2xs/hy2xs-admin.log`. Этот файл отдаётся оператору через
|
||||||
`ExportLog` и попадает в diagnostics-бандл, то есть секрет утекал наружу в
|
`ExportLog` и попадает в diagnostics-бандл, то есть секрет утекал наружу в
|
||||||
@@ -525,6 +537,78 @@ upstream выберет для нового секрета. Список мар
|
|||||||
Конфигурация Hysteria остаётся доступной панели **на чтение и на выгрузку**:
|
Конфигурация Hysteria остаётся доступной панели **на чтение и на выгрузку**:
|
||||||
`GET /config/getHysteria2Config` и `POST /config/exportHysteria2Config`.
|
`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;
|
- собирать admin-компонент на target server;
|
||||||
@@ -533,6 +617,10 @@ upstream выберет для нового секрета. Список мар
|
|||||||
- раздувать оркестратор из-за особенностей панели;
|
- раздувать оркестратор из-за особенностей панели;
|
||||||
- использовать HY2XS admin как updater бинаря Hysteria2;
|
- использовать HY2XS admin как updater бинаря Hysteria2;
|
||||||
- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API;
|
- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API;
|
||||||
|
- считать `disabled=1` завершённым отзывом доступа без `/kick`;
|
||||||
|
- откатывать `disabled` из-за неудачи `/kick`;
|
||||||
|
- писать `banned_until` из пути отключения пира;
|
||||||
|
- пропускать проверку лимита устройств, когда Traffic Stats API не ответил;
|
||||||
- экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
|
- экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
|
||||||
- выгружать конфиг с секретами в открытом виде.
|
- выгружать конфиг с секретами в открытом виде.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Контракты панели
|
# Контракты панели
|
||||||
|
|
||||||
Три свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
|
Свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
|
||||||
потому ломались молча. Каждое из них закреплено тестом
|
потому ломались молча. Каждое из них закреплено тестом
|
||||||
(`tools/test/frontend-*.test.ts`) и гейтом приёмки.
|
(`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` и
|
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
|
||||||
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
||||||
|
|||||||
@@ -737,6 +737,8 @@ run_clean_install_acceptance() {
|
|||||||
run_single_owner_acceptance "$package_dir"
|
run_single_owner_acceptance "$package_dir"
|
||||||
run_secret_channel_acceptance
|
run_secret_channel_acceptance
|
||||||
run_atomic_import_acceptance
|
run_atomic_import_acceptance
|
||||||
|
run_access_revocation_acceptance
|
||||||
|
run_observability_acceptance
|
||||||
run_legacy_account_acceptance
|
run_legacy_account_acceptance
|
||||||
run_scheduler_lifecycle_acceptance
|
run_scheduler_lifecycle_acceptance
|
||||||
run_dead_config_acceptance
|
run_dead_config_acceptance
|
||||||
@@ -1525,6 +1527,166 @@ run_atomic_import_acceptance() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Отзыв доступа: обе половины официального контракта Hysteria и fail-closed
|
||||||
|
# ограничение устройств.
|
||||||
|
run_access_revocation_acceptance() {
|
||||||
|
log_step "Acceptance: disabling a peer also terminates its live session"
|
||||||
|
# Запись `disabled=1` закрывает только БУДУЩИЕ обращения к HTTP-auth:
|
||||||
|
# установленная QUIC-сессия сама по себе не рвётся. Официальная документация
|
||||||
|
# описывает `/kick` и блокировку в auth backend как пару — по отдельности не
|
||||||
|
# работает ни одна половина.
|
||||||
|
code_has apps/service/hysteria2_api.go -F -- 'func DisconnectPeers' \
|
||||||
|
|| fail "acceptance: the session disconnect primitive is missing"
|
||||||
|
code_has apps/service/peer.go -F -- 'func disconnectAfterRevoke' \
|
||||||
|
|| fail "acceptance: revoking access must go through a single disconnect path"
|
||||||
|
code_has apps/service/peer.go -F -- 'DisconnectPeers(' \
|
||||||
|
|| fail "acceptance: revoking access never reaches the Traffic Stats /kick"
|
||||||
|
|
||||||
|
log_step "Acceptance: session disconnect does not write peer state"
|
||||||
|
# Прежний Hysteria2Kick вместе с разрывом проставлял `banned_until`, поэтому
|
||||||
|
# им нельзя было воспользоваться для отключения пира: операция записала бы
|
||||||
|
# заодно временную блокировку — другой механизм с другим сроком жизни.
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||||
|
const start = source.indexOf("func DisconnectPeers");
|
||||||
|
if (start < 0) throw new Error("DisconnectPeers is missing");
|
||||||
|
const rest = source.slice(start + 1);
|
||||||
|
const end = rest.indexOf("\nfunc ");
|
||||||
|
const body = end < 0 ? rest : rest.slice(0, end);
|
||||||
|
for (const forbidden of ["banned_until", "disabled", "dao.UpdatePeer("]) {
|
||||||
|
if (body.includes(forbidden)) {
|
||||||
|
throw new Error("DisconnectPeers writes peer state: " + forbidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' || fail "acceptance: the disconnect primitive must not write peer state"
|
||||||
|
|
||||||
|
log_step "Acceptance: a partial revocation is reported by code, not by prose"
|
||||||
|
# Долговременная запись к этому моменту уже применена и НЕ откатывается:
|
||||||
|
# достигнутое безопасное состояние нельзя отменять из-за неудачи второго
|
||||||
|
# шага. Без отдельного кода оператор прочитал бы обычную ошибку как
|
||||||
|
# «не сработало, состояние прежнее».
|
||||||
|
code_has apps/model/constant/error.go -F -- 'ErrCodePeerDisconnectFailed' \
|
||||||
|
|| fail "acceptance: the partial-revocation reason code is missing"
|
||||||
|
code_has apps/service/peer_errors.go -F -- 'func PeerDisconnectError' \
|
||||||
|
|| fail "acceptance: the partial-revocation error is missing from the service layer"
|
||||||
|
code_has apps/controller/peer.go -F -- 'failService(err, c)' \
|
||||||
|
|| fail "acceptance: peer actions must map domain reasons to response codes"
|
||||||
|
code_has apps/frontend/src/utils/api-error.ts -F -- 'peer_disconnect_failed' \
|
||||||
|
|| fail "acceptance: the panel does not know the partial-revocation code"
|
||||||
|
local locale
|
||||||
|
for locale in ru en; do
|
||||||
|
code_has "apps/frontend/src/lang/package/${locale}.ts" -F -- 'peer_disconnect_failed' \
|
||||||
|
|| fail "acceptance: the partial-revocation phrase is missing from the ${locale} locale"
|
||||||
|
done
|
||||||
|
|
||||||
|
log_step "Acceptance: the device limit is fail-closed"
|
||||||
|
# Отказ Traffic Stats API превращал объявленный «Лимит устройств: 3» в
|
||||||
|
# безлимит, и узнать об этом можно было только по строке WARN в журнале.
|
||||||
|
# Форма старой дыры: возврат успеха авторизации из ветки ошибки /online.
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||||
|
const start = source.indexOf("func Hysteria2Auth");
|
||||||
|
if (start < 0) throw new Error("Hysteria2Auth is missing");
|
||||||
|
const rest = source.slice(start + 1);
|
||||||
|
const end = rest.indexOf("\nfunc ");
|
||||||
|
const body = end < 0 ? rest : rest.slice(0, end);
|
||||||
|
const code = body
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !/^\s*\/\//.test(line))
|
||||||
|
.join("\n");
|
||||||
|
if (!code.includes("hysteria2Online()")) {
|
||||||
|
throw new Error("auth no longer asks the Traffic Stats API directly");
|
||||||
|
}
|
||||||
|
// Терпимый путь отдаёт ПУСТУЮ КАРТУ БЕЗ ОШИБКИ, когда systemd отвечает
|
||||||
|
// «служба неактивна», — а этот ответ не отличается от «спросить не
|
||||||
|
// удалось». На пути принятия решения о доступе ему не место.
|
||||||
|
if (/[^a-z0-9_]Hysteria2Online\(\)/.test(code)) {
|
||||||
|
throw new Error("auth takes the display-tolerant online path");
|
||||||
|
}
|
||||||
|
const failOpen = /err != nil \{[\s\S]*?return \*peer\.Id/;
|
||||||
|
if (failOpen.test(code)) {
|
||||||
|
throw new Error("auth still returns success from the /online failure branch");
|
||||||
|
}
|
||||||
|
' || fail "acceptance: the device limit must be fail-closed"
|
||||||
|
|
||||||
|
code_has apps/service/hysteria2.go -F -- 'var hysteria2IsRunning' \
|
||||||
|
|| fail "acceptance: the systemd state seam is missing, so fail-closed is untestable"
|
||||||
|
|
||||||
|
log_step "Acceptance: the public endpoint error names no transport"
|
||||||
|
# Публичный endpoint Hysteria — UDP/QUIC, и «TCP port» закладывал в
|
||||||
|
# сообщение заведомо ложную семантику.
|
||||||
|
! code_has apps/service/hysteria2_api.go -F -- 'must be a valid TCP port' \
|
||||||
|
|| fail "acceptance: the public port error must not claim a TCP transport"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Наблюдаемость: журнал админки и страницы, которые его показывают.
|
||||||
|
run_observability_acceptance() {
|
||||||
|
log_step "Acceptance: request log entries carry a message"
|
||||||
|
# `entry.Info()` без аргумента писал `"msg":""` для КАЖДОГО запроса, и
|
||||||
|
# страница системных логов показывала пустой столбец сообщений.
|
||||||
|
code_has apps/middleware/log.go -F -- 'func RequestLogMessage' \
|
||||||
|
|| fail "acceptance: the request log message builder is missing"
|
||||||
|
! code_has apps/middleware/log.go -E -- 'entry\.(Info|Warn|Error)\(\)' \
|
||||||
|
|| fail "acceptance: request log entries are still written without a message"
|
||||||
|
|
||||||
|
log_step "Acceptance: the request log still hides query values"
|
||||||
|
# Человекочитаемое сообщение не является лазейкой: Hysteria обращается к
|
||||||
|
# машинному endpoint'у с действующим токеном в query при каждом подключении
|
||||||
|
# пира, а журнал уезжает оператору через ExportLog.
|
||||||
|
! code_has apps/middleware/log.go -F -- 'RequestURI' \
|
||||||
|
|| fail "acceptance: the request log must never carry the query string"
|
||||||
|
! code_has apps/middleware/log.go -F -- 'RawQuery' \
|
||||||
|
|| fail "acceptance: the request log must never carry query values"
|
||||||
|
|
||||||
|
log_step "Acceptance: the log page keeps its response shape on every branch"
|
||||||
|
# Ветка «файла ещё нет» отвечала голым массивом, панель читала
|
||||||
|
# `data.records` и получала undefined: на свежепоставленном хосте страница
|
||||||
|
# системных логов не работала вовсе.
|
||||||
|
! code_has apps/controller/log.go -F -- 'vo.Success(logSystemVos, c)' \
|
||||||
|
|| fail "acceptance: the system log page must always answer with {records,total}"
|
||||||
|
# Отказ внутри цикла писал ответ и продолжал перебор, а в конце безусловно
|
||||||
|
# выполнялся vo.Success: клиент получал два JSON-документа подряд.
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("apps/controller/log.go", "utf8");
|
||||||
|
const start = source.indexOf("func LogSystem");
|
||||||
|
if (start < 0) throw new Error("LogSystem is missing");
|
||||||
|
const rest = source.slice(start + 1);
|
||||||
|
const end = rest.indexOf("\nfunc ");
|
||||||
|
const body = end < 0 ? rest : rest.slice(0, end);
|
||||||
|
const code = body
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !/^\s*\/\//.test(line))
|
||||||
|
.join("\n");
|
||||||
|
if (/vo\.Fail\([\s\S]*?\n\s*continue\b/.test(code)) {
|
||||||
|
throw new Error("a broken log line still writes a response and keeps looping");
|
||||||
|
}
|
||||||
|
' || fail "acceptance: a broken log line must be skipped, not answered"
|
||||||
|
|
||||||
|
log_step "Acceptance: both log pages share one table"
|
||||||
|
# Страницы были побайтово одинаковы и несли одни и те же дефекты в двух
|
||||||
|
# экземплярах: колонки без ширин и выгрузку с запросом вне try.
|
||||||
|
[ -f apps/frontend/src/components/LogViewer/index.vue ] \
|
||||||
|
|| fail "acceptance: the shared log table component is missing"
|
||||||
|
local page
|
||||||
|
for page in system hysteria; do
|
||||||
|
code_has "apps/frontend/src/views/log/${page}/index.vue" -F -- 'LogViewer' \
|
||||||
|
|| fail "acceptance: the ${page} log page does not use the shared table"
|
||||||
|
! code_has "apps/frontend/src/views/log/${page}/index.vue" -F -- '<el-table-column' \
|
||||||
|
|| fail "acceptance: the ${page} log page carries its own table again"
|
||||||
|
done
|
||||||
|
code_has apps/frontend/src/components/LogViewer/index.vue -E -- 'min-width="[0-9]+"' \
|
||||||
|
|| fail "acceptance: log table columns must declare explicit widths"
|
||||||
|
|
||||||
|
log_step "Acceptance: file download is built in exactly one place"
|
||||||
|
# Последовательность Blob/createObjectURL/ссылка/клик жила в четырёх копиях,
|
||||||
|
# и все успели разойтись: две из них ставили сетевой запрос ВНЕ try и
|
||||||
|
# глушили причину пустым catch.
|
||||||
|
local download_hits
|
||||||
|
download_hits="$(code_mentions_in 'createObjectURL' apps/frontend/src)"
|
||||||
|
[ "$download_hits" = "apps/frontend/src/utils/download.ts" ] \
|
||||||
|
|| fail "acceptance: file download must live in utils/download.ts alone. Found: $download_hits"
|
||||||
|
}
|
||||||
|
|
||||||
# Границы транзакции операции: снятие guard'а и взаимное исключение операций.
|
# Границы транзакции операции: снятие guard'а и взаимное исключение операций.
|
||||||
run_transaction_boundary_acceptance() {
|
run_transaction_boundary_acceptance() {
|
||||||
log_step "Acceptance: disarming the firewall guard is proven, not announced"
|
log_step "Acceptance: disarming the firewall guard is proven, not announced"
|
||||||
|
|||||||
@@ -241,3 +241,235 @@ describe("секрет пира", () => {
|
|||||||
expect(form).toContain("await savePeerApi(dataForm)");
|
expect(form).toContain("await savePeerApi(dataForm)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function readSource(...segments: string[]): string {
|
||||||
|
return fs.readFileSync(path.join(FRONTEND_SRC, ...segments), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Содержимое файла без комментариев.
|
||||||
|
*
|
||||||
|
* Проверять положено КОД, а не упоминания. Комментарий, объясняющий, почему
|
||||||
|
* конструкции здесь больше нет, обязан называть её по имени, иначе он
|
||||||
|
* бесполезен, — и проверка «такого вызова в файле нет» начинает падать ровно
|
||||||
|
* на той документации, которая описывает выполненную ею же работу.
|
||||||
|
* Отбрасываются строчные `//` и строки тела блочных комментариев `*`.
|
||||||
|
*/
|
||||||
|
function codeOf(source: string): string {
|
||||||
|
return source
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !/^\s*(\/\/|\/?\*)/.test(line))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_PAGES = [
|
||||||
|
["views", "log", "system", "index.vue"],
|
||||||
|
["views", "log", "hysteria", "index.vue"],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("страницы журнала", () => {
|
||||||
|
// Обе страницы были побайтово одинаковы и несли одни и те же три дефекта в
|
||||||
|
// двух экземплярах: колонки без ширин, сетевой запрос ВНЕ try и пустой
|
||||||
|
// catch. Починка в двух местах разошлась бы снова.
|
||||||
|
test("обе страницы построены на общем LogViewer", () => {
|
||||||
|
for (const page of LOG_PAGES) {
|
||||||
|
const source = readSource(...page);
|
||||||
|
expect(source).toContain("@/components/LogViewer/index.vue");
|
||||||
|
// Собственной таблицы у страницы быть не должно: ширины колонок живут в
|
||||||
|
// одном месте, иначе они снова разъедутся.
|
||||||
|
expect(source).not.toContain("<el-table-column");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ширины колонок журнала заданы явно", () => {
|
||||||
|
const viewer = readSource("components", "LogViewer", "index.vue");
|
||||||
|
|
||||||
|
// Без width/min-width Element Plus делит ширину практически поровну, и
|
||||||
|
// сообщение — единственное содержимое журнала — получает треть строки.
|
||||||
|
const columns = [...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g)].map(
|
||||||
|
(match) => match[0]
|
||||||
|
);
|
||||||
|
expect(columns.length).toBeGreaterThanOrEqual(3);
|
||||||
|
for (const column of columns) {
|
||||||
|
expect(column).toMatch(/\b(width|min-width)=/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонка сообщения растягивается за счёт остатка, служебные — фиксированы.
|
||||||
|
expect(viewer).toMatch(/prop="msg"[\s\S]*?min-width="\d+"/);
|
||||||
|
expect(viewer).toMatch(/prop="level"[\s\S]*?width="\d+"/);
|
||||||
|
expect(viewer).toMatch(/prop="time"[\s\S]*?width="\d+"/);
|
||||||
|
|
||||||
|
// Диагностический JSON Hysteria переносится, а не обрезается многоточием.
|
||||||
|
expect(viewer).toContain("pre-wrap");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Регрессия LOG-03. Было:
|
||||||
|
//
|
||||||
|
// const response = await exportLogApi(...); // запрос ВНЕ try
|
||||||
|
// try { ... } catch (e) { /* empty */ }
|
||||||
|
//
|
||||||
|
// Отказ самого запроса этим catch не ловился вовсе, а всё внутри глушилось
|
||||||
|
// молча: оператор не получал ни файла, ни причины.
|
||||||
|
test("выгрузка журнала выполняется внутри try и сообщает об отказе", () => {
|
||||||
|
const viewer = readSource("components", "LogViewer", "index.vue");
|
||||||
|
|
||||||
|
const exportCall = viewer.indexOf("await exportLogApi");
|
||||||
|
const tryBlock = viewer.indexOf("try {");
|
||||||
|
expect(exportCall).toBeGreaterThan(-1);
|
||||||
|
expect(tryBlock).toBeGreaterThan(-1);
|
||||||
|
expect(tryBlock).toBeLessThan(exportCall);
|
||||||
|
|
||||||
|
// Пустой catch — это и есть проглатывание причины.
|
||||||
|
expect(viewer).not.toMatch(/catch[^)]*\)?\s*\{\s*(\/\*[\s\S]*?\*\/)?\s*\}/);
|
||||||
|
expect(viewer).toContain("log.exportFailed");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сборка ссылки на скачивание жила в трёх копиях и все три успели
|
||||||
|
// разойтись. Копия обязана быть одна.
|
||||||
|
test("скачивание файла собрано в одном месте", () => {
|
||||||
|
const owners: string[] = [];
|
||||||
|
for (const file of sourceFiles(FRONTEND_SRC)) {
|
||||||
|
const source = fs.readFileSync(file, "utf8");
|
||||||
|
if (source.includes("createObjectURL")) {
|
||||||
|
owners.push(path.relative(FRONTEND_SRC, file).replace(/\\/g, "/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(owners).toEqual(["utils/download.ts"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("действия над пиром", () => {
|
||||||
|
const peerList = () => readSource("views", "peer", "list", "index.vue");
|
||||||
|
|
||||||
|
// Регрессия UX-06: пункта быстрого включения/отключения в меню не было
|
||||||
|
// вовсе, хотя это самое частое действие оператора.
|
||||||
|
test("меню строки содержит быстрое включение/отключение", () => {
|
||||||
|
const source = peerList();
|
||||||
|
expect(source).toContain('command="toggle-disabled"');
|
||||||
|
expect(source).toContain("peer.enablePeer");
|
||||||
|
expect(source).toContain("peer.disablePeer");
|
||||||
|
});
|
||||||
|
|
||||||
|
// el-dropdown объявляет command штатным способом описывать меню действий:
|
||||||
|
// команда приходит в одно место, и добавить пункт, забыв его подключить,
|
||||||
|
// становится невозможно.
|
||||||
|
test("меню строки построено на command, а не на click по пунктам", () => {
|
||||||
|
const source = peerList();
|
||||||
|
expect(source).toContain("@command=");
|
||||||
|
|
||||||
|
const items = [...source.matchAll(/<el-dropdown-item[\s\S]*?>/g)].map(
|
||||||
|
(match) => match[0]
|
||||||
|
);
|
||||||
|
expect(items.length).toBeGreaterThanOrEqual(6);
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item).toContain("command=");
|
||||||
|
expect(item).not.toContain("@click");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Регрессия UX-07: подсказка без ограничения ширины растягивалась почти на
|
||||||
|
// весь экран одной строкой. Ограничение ставится штатным popper-style, а не
|
||||||
|
// глобальным CSS.
|
||||||
|
test("широкая подсказка ограничена popper-style", () => {
|
||||||
|
const source = peerList();
|
||||||
|
expect(source).toContain(":popper-style=");
|
||||||
|
expect(source).toMatch(/maxWidth:\s*"\d+px"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Регрессия UX-09: ElMessageBox отклоняет промис при нажатии «Отмена», и
|
||||||
|
// `await ElMessageBox.confirm(...)` без разбора отказа оставлял
|
||||||
|
// необработанное отклонение промиса на каждую отмену.
|
||||||
|
test("отмена подтверждения не считается ошибкой", () => {
|
||||||
|
// Считается КОД, а не упоминания: комментарий, объясняющий, почему прямой
|
||||||
|
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
|
||||||
|
// бесполезен. Это тот же урок, что записан в code_without_comments в
|
||||||
|
// acceptance.sh, и он повторился прямо на этой проверке.
|
||||||
|
const confirms = [...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g)];
|
||||||
|
// Единственный вызов — внутри confirmAction, который переводит отмену в
|
||||||
|
// обычное `false`.
|
||||||
|
expect(confirms.length).toBe(1);
|
||||||
|
expect(peerList()).toContain("async function confirmAction");
|
||||||
|
expect(peerList()).toContain("await confirmAction(");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Частичный результат отзыва доступа — не отказ операции: запрет новых
|
||||||
|
// подключений уже записан. Панель обязана отличать его по коду и обновлять
|
||||||
|
// строку, а не показывать «не сработало».
|
||||||
|
test("частичный результат отзыва доступа разбирается по коду", () => {
|
||||||
|
const source = peerList();
|
||||||
|
expect(source).toContain("ERR_CODE.peerDisconnectFailed");
|
||||||
|
expect(source).toContain("ElMessage.warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Срок временной блокировки называется оператору: раньше `Date.now() + час`
|
||||||
|
// был зашит в обработчик и не сообщался ни до, ни после.
|
||||||
|
test("временная блокировка подтверждается и называет срок", () => {
|
||||||
|
const source = peerList();
|
||||||
|
expect(source).toContain("peer.kickConfirm");
|
||||||
|
expect(source).toContain("KICK_DURATION_MS");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Об одном результате оператор узнаёт ОДИН раз. Действия строки сообщают
|
||||||
|
// исход сами, поэтому общий перехватчик обязан молчать: иначе рядом с
|
||||||
|
// предупреждением о частичном отзыве доступа встанет ошибка от перехватчика —
|
||||||
|
// два противоречащих друг другу сообщения об одном событии.
|
||||||
|
test("действия строки не получают второго тоста от перехватчика", () => {
|
||||||
|
const api = codeOf(readSource("api", "peer", "index.ts"));
|
||||||
|
|
||||||
|
const selfReporting = [
|
||||||
|
"deletePeerApi",
|
||||||
|
"resetPeerTrafficApi",
|
||||||
|
"releaseKickPeerApi",
|
||||||
|
"kickPeerApi",
|
||||||
|
"updatePeerApi",
|
||||||
|
"savePeerApi",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of selfReporting) {
|
||||||
|
const start = api.indexOf(`export function ${name}`);
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const rest = api.slice(start + 1);
|
||||||
|
const end = rest.indexOf("\nexport function ");
|
||||||
|
const body = end < 0 ? rest : rest.slice(0, end);
|
||||||
|
expect(body).toContain("skipErrorToast: true");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Флаг относится только к отказу API. У транспортного отказа конверта нет,
|
||||||
|
// страница о нём сказать ничего не может и молчит — а пока флаг гасил и эту
|
||||||
|
// ветку, обрыв соединения при сохранении, удалении или отзыве доступа не
|
||||||
|
// показывал оператору ничего вовсе.
|
||||||
|
test("транспортный отказ показывается всегда", () => {
|
||||||
|
const request = codeOf(readSource("utils", "request.ts"));
|
||||||
|
|
||||||
|
const handler = request.slice(request.indexOf("(error: AxiosError)"));
|
||||||
|
expect(handler).toContain("ElMessage.error(message)");
|
||||||
|
expect(handler).not.toContain("skipErrorToast");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("подсказки формы пира", () => {
|
||||||
|
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
||||||
|
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
||||||
|
// пробовать разрешённые символы.
|
||||||
|
test("подсказка имени описывает действующий набор символов", () => {
|
||||||
|
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
|
||||||
|
// описывать именно его, а не набор из предыдущего поколения правила.
|
||||||
|
const rule = fs.readFileSync(
|
||||||
|
path.join(REPO_ROOT, "apps", "service", "peer_import.go"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const declared = rule.match(/PeerNameCharset\s*=\s*`([^`]+)`/);
|
||||||
|
expect(declared).not.toBeNull();
|
||||||
|
|
||||||
|
// «a-z A-Z 0-9 !@#$%^&*()_+-=» -> «!@#$%^&*()_+-=»
|
||||||
|
const punctuation = declared![1].split(/\s+/).at(-1)!;
|
||||||
|
expect(punctuation).toContain("!@#");
|
||||||
|
|
||||||
|
for (const dictionary of [ru, en] as const) {
|
||||||
|
const hint = (dictionary as any).peer?.form?.nameHint ?? "";
|
||||||
|
expect(hint).toContain(punctuation);
|
||||||
|
expect(hint).toContain("32");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user