From 162759c59972c1f66a74b03ef5b23f4b26f5f3eb Mon Sep 17 00:00:00 2001 From: Crimson Date: Tue, 1 Sep 2026 17:17:17 +0500 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=D0=B4=D0=BE=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B8=D1=82=D1=8C=20=D0=B2=D1=82=D0=BE=D1=80=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D0=BE=D0=B2=D0=B8=D0=BD=D1=8B=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B7=D1=8B=D0=B2=D0=B0=20=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=D0=B0,=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D0=B0=20=D0=B8?= =?UTF-8?q?=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбор кода на 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. --- CHANGELOG.md | 180 +++++ apps/controller/log.go | 39 +- apps/controller/log_test.go | 134 ++++ apps/controller/peer.go | 6 +- apps/controller/peer_validation_test.go | 43 ++ apps/frontend/eslint.config.mjs | 14 +- apps/frontend/src/api/log/types.ts | 8 +- apps/frontend/src/api/peer/index.ts | 13 + .../src/components/LogViewer/index.vue | 169 +++++ apps/frontend/src/lang/package/en.ts | 33 +- apps/frontend/src/lang/package/ru.ts | 31 +- .../frontend/src/layout/components/Navbar.vue | 25 +- apps/frontend/src/types/components.d.ts | 1 + apps/frontend/src/utils/api-error.ts | 5 + apps/frontend/src/utils/download.ts | 73 +++ apps/frontend/src/utils/request.ts | 26 +- .../src/views/hysteria/list/index.vue | 34 +- .../frontend/src/views/log/hysteria/index.vue | 113 +--- apps/frontend/src/views/log/system/index.vue | 114 +--- apps/frontend/src/views/peer/list/index.vue | 318 +++++++-- apps/middleware/log.go | 31 +- apps/middleware/log_test.go | 66 ++ apps/model/constant/error.go | 13 + apps/proxy/hysteria2_api.go | 32 +- apps/service/hysteria2.go | 22 + apps/service/hysteria2_api.go | 158 ++++- apps/service/peer.go | 66 +- apps/service/peer_access_test.go | 620 ++++++++++++++++++ apps/service/peer_bootstrap_guard_test.go | 5 + apps/service/peer_errors.go | 21 + .../2026-09-01-v1.0.0-rc1-ux-findings.md | 9 + ...026-09-01-v1.0.0-rc2-preflight-findings.md | 420 ++++++++++++ docs/acceptance/README.md | 10 + docs/admin/04-admin-panel.md | 90 ++- docs/admin/15-ui-contracts.md | 79 ++- tools/build/lib/acceptance.sh | 162 +++++ tools/test/frontend-contract.test.ts | 232 +++++++ 37 files changed, 3072 insertions(+), 343 deletions(-) create mode 100644 apps/controller/log_test.go create mode 100644 apps/frontend/src/components/LogViewer/index.vue create mode 100644 apps/frontend/src/utils/download.ts create mode 100644 apps/service/peer_access_test.go create mode 100644 docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a658e43..0ed1ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: ` именно при отказе 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`, и отсутствующее в списке имя падало ошибкой линтера на + совершенно корректном коде. + ### Исправлено — панель оператора - **Необязательный секрет пира был фактически обязателен.** Панель обещала diff --git a/apps/controller/log.go b/apps/controller/log.go index 240e7c5..35747f3 100644 --- a/apps/controller/log.go +++ b/apps/controller/log.go @@ -17,10 +17,20 @@ func LogSystem(c *gin.Context) { if err != nil { return } - exists := util.Exists(constant.SystemLogPath) 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 } numLine := 0 @@ -38,9 +48,26 @@ func LogSystem(c *gin.Context) { continue } 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 } // Собственный журнал санитизируется так же, как чужой. diff --git a/apps/controller/log_test.go b/apps/controller/log_test.go new file mode 100644 index 0000000..472ca02 --- /dev/null +++ b/apps/controller/log_test.go @@ -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) + } +} diff --git a/apps/controller/peer.go b/apps/controller/peer.go index e0cee2a..34c0116 100644 --- a/apps/controller/peer.go +++ b/apps/controller/peer.go @@ -285,8 +285,12 @@ func KickPeer(c *gin.Context) { if err != nil { return } + // failService, а не vo.Fail: отзыв доступа умеет завершиться ЧАСТИЧНО — + // блокировка записана, активную сессию завершить не удалось. Через + // vo.Fail этот результат уезжал бы панели неотличимо от полного отказа, и + // оператор сделал бы неверный вывод о состоянии пира. if err = service.KickPeer(id, *kickDto.BannedUntil); err != nil { - vo.Fail(err.Error(), c) + failService(err, c) return } vo.Success(nil, c) diff --git a/apps/controller/peer_validation_test.go b/apps/controller/peer_validation_test.go index f27840a..f5b596c 100644 --- a/apps/controller/peer_validation_test.go +++ b/apps/controller/peer_validation_test.go @@ -2,9 +2,11 @@ package controller import ( "encoding/json" + "net" "net/http" "net/http/httptest" "net/url" + "os" "path/filepath" "strconv" "strings" @@ -37,6 +39,46 @@ func newPeerControllerDB(t *testing.T) { 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 — тело создания пира со всеми обязательными полями. // Тесты меняют в нём ровно то, что проверяют. func peerPayload(name string) map[string]any { @@ -92,6 +134,7 @@ func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) { for label, mutate := range cases { t.Run(label, func(t *testing.T) { newPeerControllerDB(t) + answerTrafficStats(t) body := peerPayload("client-01") mutate(body) diff --git a/apps/frontend/eslint.config.mjs b/apps/frontend/eslint.config.mjs index c726089..026bf6b 100644 --- a/apps/frontend/eslint.config.mjs +++ b/apps/frontend/eslint.config.mjs @@ -57,9 +57,17 @@ export default tseslint.config( ...globals.browser, ...globals.node, ...autoImportGlobals, - // Объявлены в src/types/global.d.ts и используются как глобальные типы. - DialogType: "readonly", - OptionType: "readonly", + // Объявлены в src/types/global.d.ts и используются как глобальные + // типы. Списку положено совпадать с содержимым того файла: `no-undef` + // не знает о declare global, и отсутствующее здесь имя падает ошибкой + // линтера при совершенно корректном коде. + // + // DialogType и OptionType отсюда убраны: в src/types/global.d.ts их + // нет и в панели они не встречаются — это остаток чужого шаблона, + // разрешавший необъявленные имена. + IdDto: "readonly", + BaseDto: "readonly", + PageVo: "readonly", }, }, rules: { diff --git a/apps/frontend/src/api/log/types.ts b/apps/frontend/src/api/log/types.ts index 398a3a6..9141c20 100644 --- a/apps/frontend/src/api/log/types.ts +++ b/apps/frontend/src/api/log/types.ts @@ -8,7 +8,11 @@ export interface LogExportDto { export interface LogSystemVo { clientIp: string; - latencyTime: string; + // Числа объявлены числами: сервер шлёт их из vo.LogSystemVo как int64 + // (latencyTime, statusCode), а не строками. Пока обе колонки не + // отображались, расхождение было безвредным; после того как строка журнала + // стала читаемой, оно стало бы обычной ошибкой сравнения или форматирования. + latencyTime: number; level: string; msg: string; reqMethod: string; @@ -17,7 +21,7 @@ export interface LogSystemVo { reqPath: string; // Только имена переданных query-параметров, без значений. reqQueryKeys: string; - statusCode: string; + statusCode: number; time: string; } diff --git a/apps/frontend/src/api/peer/index.ts b/apps/frontend/src/api/peer/index.ts index 85f3fdc..6873d77 100644 --- a/apps/frontend/src/api/peer/index.ts +++ b/apps/frontend/src/api/peer/index.ts @@ -35,10 +35,20 @@ export function pagePeerApi(data: PeerPageDto): AxiosPromise> { }); } +// Действия над строкой пира сообщают свой исход сами (см. reportPeerActionError +// в списке пиров), поэтому общий тост им не нужен: он был бы вторым сигналом о +// том же событии. +// +// Для отзыва доступа это не косметика. Операция умеет завершиться ЧАСТИЧНО — +// запрет новых подключений записан, активную сессию завершить не удалось, — и +// страница показывает такой исход предупреждением. Общий перехватчик показал бы +// рядом ошибку, то есть два противоречащих друг другу сообщения об одном +// результате. export function deletePeerApi(data: IdDto): AxiosPromise { return request({ url: `/peers/${data.id}`, method: "delete", + skipErrorToast: true, }); } @@ -55,6 +65,7 @@ export function resetPeerTrafficApi(data: IdDto): AxiosPromise { return request({ url: `/peers/${data.id}/reset-traffic`, method: "patch", + skipErrorToast: true, }); } @@ -62,6 +73,7 @@ export function releaseKickPeerApi(data: IdDto): AxiosPromise { return request({ url: `/peers/${data.id}/release-kick`, method: "patch", + skipErrorToast: true, }); } @@ -70,6 +82,7 @@ export function kickPeerApi(id: number, data: KickPeerDto): AxiosPromise { url: `/peers/${id}/kick`, method: "patch", data, + skipErrorToast: true, }); } diff --git a/apps/frontend/src/components/LogViewer/index.vue b/apps/frontend/src/components/LogViewer/index.vue new file mode 100644 index 0000000..ebfde33 --- /dev/null +++ b/apps/frontend/src/components/LogViewer/index.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 709c63a..ad00ca6 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -154,12 +154,18 @@ export default { credential_format: "“{field}”: contains characters that are not allowed", rule_violated: "“{field}”: value is not acceptable", 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_reserved: "This name is reserved for the installer peer", 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.", 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", unauthorized: "Signing in is required", session_expired: "Session expired", @@ -185,8 +191,10 @@ export default { secret: "Secret", form: { 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: - "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", remarkHint: "Optional operator note. It is never shown to the client.", secretPlaceholder: "leave empty to generate one", @@ -230,10 +238,22 @@ export default { conAt: "Last connection time", createTime: "Create Time", releaseSuccess: "Release successful", - kick: "Kick", - kickTip: "Force user to log off", - releaseKick: "Release", - releaseKickTip: "Remove offline status", + kick: "Ban temporarily", + kickTip: + "Drop the session and refuse new connections until the ban expires", + 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", bannedUntil: "Banned until", totalTraffic: "Total traffic", @@ -264,6 +284,7 @@ export default { level: "Level", message: "Message", time: "Time", + exportFailed: "Could not export the log", }, errorPage: { back: "Back", diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index 613c4dc..85df1ba 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -163,6 +163,11 @@ export default { peer_bootstrap_identity_locked: "Имя и секрет пира установщика продублированы в файле на сервере и не меняются через панель. Ненужный bootstrap-пир следует удалить целиком.", invalid_credentials: "Неверный логин или пароль", + // Фраза обязана начинаться с того, что УЖЕ СДЕЛАНО: иначе оператор + // прочитает её как «операция не выполнена» и повторит действие, которое + // на самом деле применилось. + peer_disconnect_failed: + "Новые подключения пира запрещены, но завершить его активную сессию не удалось: Traffic Stats API Hysteria недоступен. Установленное соединение может работать до переподключения клиента. Проверьте состояние службы hysteria-server и повторите действие.", import_file_extension: "Импорт принимает только файлы .json", unauthorized: "Требуется вход в панель", session_expired: "Сессия истекла", @@ -189,8 +194,12 @@ export default { secret: "Секрет", form: { namePlaceholder: "client-01", + // Набор символов назван так, как его понимает service.IsValidPeerName. + // Прежняя формулировка «латиница, цифры и дефис» осталась от правила, + // действовавшего до EX-03: подсказка обещала более узкий набор, чем + // сервер принимает, и оператор не имел причин пробовать остальные. nameHint: - "Идентификатор пира: от 6 до 32 символов, латиница, цифры и дефис. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.", + "Идентификатор пира: от 6 до 32 символов — латиница, цифры и знаки !@#$%^&*()_+-=. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.", remarkPlaceholder: "ноутбук", remarkHint: "Необязательная пометка для оператора. Клиент её не видит.", secretPlaceholder: "оставьте пустым — сгенерируем", @@ -239,10 +248,21 @@ export default { conAt: "Последнее подключение", createTime: "Создано", releaseSuccess: "Ограничение снято", - kick: "Отключить", - kickTip: "Принудительно отключить пользователя", - releaseKick: "Снять", - releaseKickTip: "Снять офлайн-статус", + kick: "Временно заблокировать", + kickTip: "Прервать соединение и не пускать до истечения срока", + kickConfirm: + "Заблокировать пир «{name}» до {until}? Текущее соединение будет разорвано, новые подключения до этого времени приниматься не будут.", + kickSuccess: "Пир временно заблокирован", + releaseKick: "Снять блокировку", + releaseKickTip: "Снять временную блокировку досрочно", + // Отключение и временная блокировка — разные механизмы: у блокировки есть + // срок, который истекает сам, отключение снимается только руками. + enablePeer: "Включить пир", + disablePeer: "Отключить пир", + disableConfirm: + "Отключить пир «{name}»? Текущее соединение будет разорвано, а новые подключения запрещены, пока пир не будет включён обратно.", + enableSuccess: "Пир включён", + disableSuccess: "Пир отключён", }, config: { orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure", @@ -268,6 +288,7 @@ export default { level: "Уровень", message: "Сообщение", time: "Время", + exportFailed: "Не удалось выгрузить журнал", }, errorPage: { back: "Назад", diff --git a/apps/frontend/src/layout/components/Navbar.vue b/apps/frontend/src/layout/components/Navbar.vue index f79684c..5bbd550 100644 --- a/apps/frontend/src/layout/components/Navbar.vue +++ b/apps/frontend/src/layout/components/Navbar.vue @@ -28,17 +28,24 @@ const { isFullscreen, toggle } = useFullscreen(); /** * Выход из системы. + * + * Отмена диалога — это ОТВЕТ оператора, а не ошибка. ElMessageBox отклоняет + * промис при нажатии «Отмена», и цепочка без разбора отказа оставляла в + * консоли необработанное отклонение промиса на каждый передумавший выход. */ -function logout() { - ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), { - confirmButtonText: t("navbar.logout"), - cancelButtonText: t("common.cancel"), - type: "warning", - }).then(() => { - adminStore.logout().then(() => { - router.push(`/login?redirect=${route.fullPath}`); +async function logout() { + try { + await ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), { + confirmButtonText: t("navbar.logout"), + cancelButtonText: t("common.cancel"), + type: "warning", }); - }); + } catch { + return; + } + + await adminStore.logout(); + router.push(`/login?redirect=${route.fullPath}`); } diff --git a/apps/frontend/src/types/components.d.ts b/apps/frontend/src/types/components.d.ts index d981844..8401055 100644 --- a/apps/frontend/src/types/components.d.ts +++ b/apps/frontend/src/types/components.d.ts @@ -55,6 +55,7 @@ declare module 'vue' { IEpUserFilled: typeof import('~icons/ep/user-filled')['default'] ImputMultiple: typeof import('./../components/ImputMultiple/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'] Pagination: typeof import('./../components/Pagination/index.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] diff --git a/apps/frontend/src/utils/api-error.ts b/apps/frontend/src/utils/api-error.ts index 7383020..0b39d4d 100644 --- a/apps/frontend/src/utils/api-error.ts +++ b/apps/frontend/src/utils/api-error.ts @@ -47,6 +47,11 @@ export const ERR_CODE = { peerNameTaken: "peer_name_taken", peerNameReserved: "peer_name_reserved", peerBootstrapLocked: "peer_bootstrap_identity_locked", + // ЧАСТИЧНЫЙ результат отзыва доступа, а не отказ операции: запрет новых + // подключений записан, активную сессию завершить не удалось. Панель + // показывает его предупреждением и обновляет строку — состояние в базе уже + // изменилось. + peerDisconnectFailed: "peer_disconnect_failed", invalidCredentials: "invalid_credentials", importFileExtension: "import_file_extension", unauthorized: "unauthorized", diff --git a/apps/frontend/src/utils/download.ts b/apps/frontend/src/utils/download.ts new file mode 100644 index 0000000..c567e35 --- /dev/null +++ b/apps/frontend/src/utils/download.ts @@ -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`. Прежние копии оставляли `` в 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); + } +} diff --git a/apps/frontend/src/utils/request.ts b/apps/frontend/src/utils/request.ts index ae4d316..d2fc3fb 100644 --- a/apps/frontend/src/utils/request.ts +++ b/apps/frontend/src/utils/request.ts @@ -21,7 +21,13 @@ const t = i18n.global.t; * Запрос может отказаться от общего тоста, если показывает причину сам. * * Так делает форма пира: причины по полям она подставляет прямо под поля, и - * второй сигнал тостом там только шумит. + * второй сигнал тостом там только шумит. Так же поступают действия над строкой + * пира: отзыв доступа умеет завершиться частично, и такой исход показывается + * предупреждением, а не ошибкой. + * + * Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code` + * и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего, + * и молчание о нём означало бы операцию, не происходящую без объяснений. */ declare module "axios" { export interface AxiosRequestConfig { @@ -138,9 +144,21 @@ service.interceptors.response.use( ? t("common.systemError") : t("common.networkError"); - if (!error.config?.skipErrorToast) { - ElMessage.error(message); - } + // skipErrorToast здесь НЕ действует, и это существенно. + // + // Флаг существует ради одного случая: страница разбирает причину отказа + // сама — раскладывает её по полям формы или отличает частичный результат + // от полного отказа по коду. Всё это возможно только для отказа API, у + // которого есть конверт с `code` и `errors`. + // + // У транспортного отказа конверта нет вовсе. Страница о нём не может + // сказать ничего, кроме того же «сервер не отвечает», и все они пишут в + // своих обработчиках `// транспортный отказ уже показан общим + // перехватчиком» и молчат. Пока флаг гасил и эту ветку, утверждение было + // ложным: обрыв соединения при сохранении пира, его удалении или отзыве + // доступа не показывал оператору НИЧЕГО — операция просто не происходила + // молча. + ElMessage.error(message); return Promise.reject(error); } ); diff --git a/apps/frontend/src/views/hysteria/list/index.vue b/apps/frontend/src/views/hysteria/list/index.vue index f76de5f..4ed12eb 100644 --- a/apps/frontend/src/views/hysteria/list/index.vue +++ b/apps/frontend/src/views/hysteria/list/index.vue @@ -1050,6 +1050,7 @@ import Outbounds from "./components/Outbounds/index.vue"; import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config"; import { useI18n } from "vue-i18n"; import { deepCopy } from "@/utils/copy"; +import { saveResponseAsFile } from "@/utils/download"; import { dashboardSummaryApi } from "@/api/dashboard"; const { t } = useI18n(); @@ -1146,23 +1147,26 @@ const { hysteria2Monitor, } = toRefs(state); +/** + * Выгрузка серверного конфига Hysteria. + * + * Тот же дефект, что был на обеих страницах журнала: сетевой запрос стоял + * ПЕРЕД try, поэтому его отказ этим catch не ловился вовсе, а сам catch был + * пуст. Вдобавок `dis.split(...)` падал на отсутствующем Content-Disposition — + * и это исключение тоже глушилось. Оператор нажимал «Экспорт» и не получал ни + * файла, ни причины. + * + * Сборка ссылки на скачивание живёт в одном месте на всю панель. + */ const handleExport = async () => { - let response = await exportHysteria2ConfigApi(); 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 */ + const response = await exportHysteria2ConfigApi(); + saveResponseAsFile(response, "hysteria-config.yaml"); + ElMessage.success(t("common.downloadSuccess")); + } catch { + // Выгрузка приходит бинарным потоком и не проходит через общий разбор + // конверта: у Blob нет полей code и errors. + ElMessage.error(t("common.systemError")); } }; diff --git a/apps/frontend/src/views/log/hysteria/index.vue b/apps/frontend/src/views/log/hysteria/index.vue index 1986ee2..accf78a 100644 --- a/apps/frontend/src/views/log/hysteria/index.vue +++ b/apps/frontend/src/views/log/hysteria/index.vue @@ -1,109 +1,24 @@ diff --git a/apps/frontend/src/views/log/system/index.vue b/apps/frontend/src/views/log/system/index.vue index 98e3e8b..0ff9014 100644 --- a/apps/frontend/src/views/log/system/index.vue +++ b/apps/frontend/src/views/log/system/index.vue @@ -1,109 +1,25 @@ diff --git a/apps/frontend/src/views/peer/list/index.vue b/apps/frontend/src/views/peer/list/index.vue index cafc727..9fd2d53 100644 --- a/apps/frontend/src/views/peer/list/index.vue +++ b/apps/frontend/src/views/peer/list/index.vue @@ -30,7 +30,17 @@ > {{ $t("common.import") }} - + + {{ $t("peer.exportSettings") }} @@ -132,35 +142,53 @@ @click="copyUri(peerRow(scope.row))" >{{ $t("peer.copyUri") }} - + + @@ -214,7 +242,10 @@ />
{{ $t("peer.form.secretHint") }}
- +
{{ $t("peer.form.quotaHint") }}
@@ -231,7 +262,9 @@ :error="serverErrors.maxDevices" >
- @@ -381,8 +414,34 @@ import { PeerVo, } from "@/api/peer/types"; 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 { 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; } 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]; } } @@ -625,12 +687,14 @@ async function submitForm() { const ok = await formRef.value.validate().catch(() => false); if (!ok) return; } - if (dialog.editId > 0 && dataForm.secret) { - await ElMessageBox.confirm( - t("peer.secretRotateConfirm"), - t("common.warning"), - { type: "warning" } - ); + // Отмена подтверждения — ответ оператора, а не ошибка: форма остаётся + // открытой с введённым, и в консоль ничего не улетает. + if ( + dialog.editId > 0 && + dataForm.secret && + !(await confirmAction(t("peer.secretRotateConfirm"))) + ) { + return; } 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 = { type: "warning" } +): Promise { + 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) { - await ElMessageBox.confirm( - t("common.deleteConfirm", { username: row.name }), - t("common.warning"), - { type: "warning" } - ); - await deletePeerApi({ id: row.id }); + if ( + !(await confirmAction(t("common.deleteConfirm", { username: row.name }))) + ) { + return; + } + try { + await deletePeerApi({ id: row.id }); + ElMessage.success(t("common.success")); + } catch (error) { + reportPeerActionError(error); + } await handleQuery(); } async function handleResetTraffic(row: PeerVo) { - await ElMessageBox.confirm( - t("common.resetTrafficConfirm"), - t("common.warning"), - { type: "warning" } - ); - await resetPeerTrafficApi({ id: row.id }); + if (!(await confirmAction(t("common.resetTrafficConfirm")))) { + return; + } + try { + await resetPeerTrafficApi({ id: row.id }); + ElMessage.success(t("common.success")); + } catch (error) { + reportPeerActionError(error); + } 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) { - 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(); } 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(); } async function copyUri(row: PeerVo) { + // Копирование без подтверждения выглядит как ничего не сделавшая кнопка: + // буфер обмена оператору не виден. const { data } = await getPeerClientConfigApi(row.id); copy(data.url); + ElMessage.success(t("common.copySuccess")); } async function showQr(row: PeerVo) { @@ -752,17 +963,7 @@ function beforeImport(file: UploadRawFile) { async function downloadExport(includeSecrets: boolean) { try { const response = await exportPeerApi(includeSecrets); - const blob = new Blob([response.data], { - 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); + saveResponseAsFile(response, "peers-export.json"); ElMessage.success(t("common.downloadSuccess")); } catch { // Выгрузка приходит бинарным потоком, поэтому её отказ не проходит через @@ -780,7 +981,7 @@ async function handleExport() { // Подтверждение здесь обязательно — это единственное место в панели, где // оператор осознанно выгружает наружу работающие учётные данные доступа. async function handleExportBackup() { - await ElMessageBox.confirm( + const confirmed = await confirmAction( t("peer.exportBackupConfirm"), t("common.securityRisk"), { @@ -790,6 +991,9 @@ async function handleExportBackup() { confirmButtonClass: "el-button--danger", } ); + if (!confirmed) { + return; + } await downloadExport(true); } diff --git a/apps/middleware/log.go b/apps/middleware/log.go index 0f84108..6078a96 100644 --- a/apps/middleware/log.go +++ b/apps/middleware/log.go @@ -1,6 +1,7 @@ package middleware import ( + "fmt" "sort" "strings" "time" @@ -79,13 +80,37 @@ func LogHandler() gin.HandlerFunc { } entry := logrus.WithFields(fields) + message := RequestLogMessage(c.Request.Method, c.Request.URL.Path, statusCode, latencyTime) if statusCode >= 500 { - entry.Error() + entry.Error(message) } else if statusCode >= 400 { - entry.Warn() + entry.Warn(message) } 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= при каждом подключении +// пира; журнал отдаётся оператору через 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()) +} diff --git a/apps/middleware/log_test.go b/apps/middleware/log_test.go index 0ef0c15..93ff4af 100644 --- a/apps/middleware/log_test.go +++ b/apps/middleware/log_test.go @@ -2,6 +2,7 @@ package middleware import ( "bytes" + "encoding/json" "net/http" "net/http/httptest" "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) { diff --git a/apps/model/constant/error.go b/apps/model/constant/error.go index 6142d3e..f2eefd1 100644 --- a/apps/model/constant/error.go +++ b/apps/model/constant/error.go @@ -61,6 +61,19 @@ const ( ErrCodeInvalidCredentials string = "invalid_credentials" ErrCodeImportFileExtension string = "import_file_extension" + // ErrCodePeerDisconnectFailed — ЧАСТИЧНЫЙ результат, а не отказ операции. + // + // Отзыв доступа состоит из двух половин: долговременной записи в базе, + // которая закрывает новые подключения, и обращения к Traffic Stats `/kick`, + // которое рвёт уже установленную сессию. Первая половина к моменту этого + // кода уже применена и НЕ откатывается: достигнутое безопасное состояние + // нельзя отменять из-за неудачи второго шага — откат вернул бы пиру полный + // доступ. + // + // Поэтому код нужен отдельный. Без него оператор видел бы обычную ошибку и + // сделал бы ровно неверный вывод: «не сработало, состояние прежнее». + ErrCodePeerDisconnectFailed string = "peer_disconnect_failed" + // Коды состояния сессии. Панель различает «войдите» и «сессия кончилась»: // во втором случае оператор находится на рабочей странице, и молча // выбрасывать его на форму входа без объяснения нельзя. diff --git a/apps/proxy/hysteria2_api.go b/apps/proxy/hysteria2_api.go index 34cb64e..0a18a94 100644 --- a/apps/proxy/hysteria2_api.go +++ b/apps/proxy/hysteria2_api.go @@ -18,6 +18,32 @@ type Hysteria2Api struct { apiPort int64 } +// describeFailure объясняет, ПОЧЕМУ обращение к Traffic Stats API не удалось. +// +// Отказ здесь бывает двух видов, и раньше журнал различал их ровно наоборот +// тому, как нужно. Запись делалась так: +// +// if err != nil || resp.StatusCode != http.StatusOK { +// logrus.Errorf("Hysteria2 KickUsers err: %v", err) +// +// то есть при отказе САМОГО API (200 не пришёл, а транспорт сработал) в журнал +// уезжало `err: ` — сообщение без единого факта именно в том случае, +// когда 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 { return &Hysteria2Api{ 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 { - logrus.Errorf("Hysteria2 ListUsers err: %v", err) + logrus.Errorf("Hysteria2 ListUsers failed: %s", describeFailure(err, resp)) return nil, errors.New("http connection error") } 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 { - logrus.Errorf("Hysteria2 KickUsers err: %v", err) + logrus.Errorf("Hysteria2 KickUsers failed: %s", describeFailure(err, resp)) return errors.New("http connection error") } return nil @@ -111,7 +137,7 @@ func (h *Hysteria2Api) OnlineUsers(secret string) (map[string]int64, error) { } }() 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") } body, err := io.ReadAll(resp.Body) diff --git a/apps/service/hysteria2.go b/apps/service/hysteria2.go index 43d97a7..a87713c 100644 --- a/apps/service/hysteria2.go +++ b/apps/service/hysteria2.go @@ -26,7 +26,29 @@ func InitHysteria2() error { return nil } +// Hysteria2IsRunning — ответ systemd о состоянии службы, и НЕ БОЛЬШЕ ТОГО. +// +// Точность этого ответа ограничена сверху устройством util.Exec: он схлопывает +// «systemctl вернул 3, служба неактивна» и «запустить systemctl не удалось» в +// одну ошибку. Различить их здесь нельзя, поэтому false означает «служба +// неактивна ИЛИ спросить не получилось». +// +// Отсюда правило, которое стоило продукту двух дыр: на этом значении нельзя +// строить решения о доступе и о применении операции. Ему место в отображении +// (дашборд, признак online в списке), где ошибочное «выключено» стоит одной +// неверной плашки. Решения о доступе принимаются по фактическому ответу +// Traffic Stats API — см. hysteria2Online и DisconnectPeers. func Hysteria2IsRunning() bool { + return hysteria2IsRunning() +} + +// hysteria2IsRunning — та же проверка в виде значения. +// +// Переменная нужна тестам: без неё каждая проверка, зависящая от состояния +// службы, доказуема только на живом systemd, то есть не доказуема в сборке +// вовсе. Продуктовый путь от этого не меняется — значение по умолчанию и есть +// прежняя реализация. +var hysteria2IsRunning = func() bool { _, err := util.Exec("systemctl is-active --quiet hysteria-server") return err == nil } diff --git a/apps/service/hysteria2_api.go b/apps/service/hysteria2_api.go index 4c5d8e5..8d1fa82 100644 --- a/apps/service/hysteria2_api.go +++ b/apps/service/hysteria2_api.go @@ -48,7 +48,10 @@ func resolvePublicEndpoint() (string, int, error) { } port, err := strconv.Atoi(portRaw) 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 } @@ -68,67 +71,170 @@ func Hysteria2Auth(conPass string) (int64, string, error) { return 0, "", err } - // Ограничение количества устройств - onlineUsers, err := Hysteria2Online() - if err != nil { - logrus.WithError(err).Warn("hysteria2 online users unavailable; skip device-limit check") - return *peer.Id, *peer.AuthId, nil + // Строка без идентичности — повреждённые данные, а не пир. + // + // Проверка стоит здесь по той же причине, что и проверка maxDevices ниже: + // это путь КАЖДОГО подключения пира, и разыменование 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 *peer.Id, *peer.AuthId, nil } +// Hysteria2Online — картина подключений ДЛЯ ОТОБРАЖЕНИЯ. +// +// Терпима к недоступности Hysteria намеренно: дашборду и списку пиров нечего +// показать, если Traffic Stats API молчит, и пустая картина — честный ответ на +// вопрос «кто сейчас на связи», когда служба остановлена. +// +// Этой терпимости НЕТ в решениях о доступе. Ярлык `!hysteria2IsRunning() -> +// пусто, ошибки нет` раньше стоял на общем пути, и через него ограничение +// устройств обходилось молча: systemctl отвечает через util.Exec, который не +// отличает «служба неактивна» от «спросить не удалось», а «никого нет онлайн» +// для проверки лимита означает «пускать всех». Отказа при этом не возникало +// вовсе — не было даже записи в журнале. func Hysteria2Online() (map[string]int64, error) { - if !Hysteria2IsRunning() { + if !hysteria2IsRunning() { return map[string]int64{}, nil } + return hysteria2Online() +} + +// hysteria2Online — фактический ответ Traffic Stats API, без ярлыков. +// +// Недоступность здесь остаётся ошибкой: вызывающий обязан решить, что она для +// него значит, и не может получить пустую карту вместо отказа. +func hysteria2Online() (map[string]int64, error) { apiPort, err := GetHysteria2ApiPort() if err != nil { return nil, errors.New("get hysteria2 apiPort err") } - trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) + secret, err := hysteria2TrafficSecret() if err != nil { return nil, err } - onlineUsers, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(*trafficSecretConfig.Value) - if err != nil { - return nil, err - } - return onlineUsers, nil + return proxy.NewHysteria2Api(apiPort).OnlineUsers(secret) } -func Hysteria2Kick(ids []int64, kickUtilTime int64) error { - if !Hysteria2IsRunning() { - return errors.New("hysteria2 is not running") +// hysteria2TrafficSecret отдаёт секрет Traffic Stats API. +// +// Отсутствующее значение ключа — отказ, а не пустая строка. Раньше по этому +// пути стояло `*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 { - return err + if trafficSecretConfig.Value == nil || *trafficSecretConfig.Value == "" { + 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) if err != nil { return err } - var keys []string + + keys := make([]string, 0, len(peers)) for _, item := range peers { + if item.AuthId == nil || *item.AuthId == "" { + continue + } keys = append(keys, *item.AuthId) } + // Пир без authId Hysteria не знает: рвать нечего, и это не отказ. + if len(keys) == 0 { + return nil + } + apiPort, err := GetHysteria2ApiPort() if err != nil { return errors.New("get hysteria2 apiPort err") } - trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) + secret, err := hysteria2TrafficSecret() if err != nil { return err } - if err = proxy.NewHysteria2Api(apiPort).KickUsers(keys, *trafficSecretConfig.Value); err != nil { - return err - } - return nil + return proxy.NewHysteria2Api(apiPort).KickUsers(keys, secret) } func Hysteria2Url(accountId int64) (string, error) { diff --git a/apps/service/peer.go b/apps/service/peer.go index 68b4105..b5b1f10 100644 --- a/apps/service/peer.go +++ b/apps/service/peer.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/sirupsen/logrus" + "hy2xs-admin/dao" "hy2xs-admin/model/bo" "hy2xs-admin/model/constant" @@ -160,7 +162,52 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error { if peerDto.Remark != nil { 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 запрещает менять то, что продублировано @@ -246,11 +293,26 @@ func ReleaseKickPeer(id int64) error { 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 { if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil { return err } - return Hysteria2Kick([]int64{id}, bannedUntil) + return disconnectAfterRevoke(id) } func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) { diff --git a/apps/service/peer_access_test.go b/apps/service/peer_access_test.go new file mode 100644 index 0000000..b1bf309 --- /dev/null +++ b/apps/service/peer_access_test.go @@ -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) + } +} diff --git a/apps/service/peer_bootstrap_guard_test.go b/apps/service/peer_bootstrap_guard_test.go index 951835b..92c7e34 100644 --- a/apps/service/peer_bootstrap_guard_test.go +++ b/apps/service/peer_bootstrap_guard_test.go @@ -79,6 +79,11 @@ func TestCreatePeerRefusesReservedName(t *testing.T) { // оператор не смог бы ни отключить bootstrap-пира, ни ограничить ему квоту. func TestUpdatePeerAllowsNonIdentityChangesOnBootstrapPeer(t *testing.T) { newTestDB(t) + // Отключение пира — это ещё и разрыв его активных сессий, поэтому Traffic + // Stats API обязан отвечать. Без него операция завершилась бы частичным + // результатом (запись применена, сессия не разорвана), и тест доказывал бы + // не то, ради чего написан. + startTrafficStats(t, nil) id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName) disabled := int64(1) diff --git a/apps/service/peer_errors.go b/apps/service/peer_errors.go index 95a931a..7aef9e2 100644 --- a/apps/service/peer_errors.go +++ b/apps/service/peer_errors.go @@ -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 — имя уже занято другим пиром. func PeerNameTakenError(name string) *PeerError { return &PeerError{ diff --git a/docs/acceptance/2026-09-01-v1.0.0-rc1-ux-findings.md b/docs/acceptance/2026-09-01-v1.0.0-rc1-ux-findings.md index b978918..fa81c2c 100644 --- a/docs/acceptance/2026-09-01-v1.0.0-rc1-ux-findings.md +++ b/docs/acceptance/2026-09-01-v1.0.0-rc1-ux-findings.md @@ -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 1. пересобрать `rc2` и повторить build/security acceptance; diff --git a/docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md b/docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md new file mode 100644 index 0000000..e5923e7 --- /dev/null +++ b/docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md @@ -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=` при каждом подключении +пира, а журнал отдаётся оператору через `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. проверить, что отмена любого подтверждения не оставляет ошибок в консоли + браузера. diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index fa6d3b9..12ac614 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -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 | коммит `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) | diff --git a/docs/admin/04-admin-panel.md b/docs/admin/04-admin-panel.md index 69c53a8..030659c 100644 --- a/docs/admin/04-admin-panel.md +++ b/docs/admin/04-admin-panel.md @@ -113,9 +113,21 @@ Hysteria обращается к машинному endpoint'у как пира. Поэтому в журнале админки пишется **путь**, а не `RequestURI`: ```json -{ "reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" } +{ "msg": "POST /internal/hysteria/auth → 200 (2 ms)", + "reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" } ``` +Поле `msg` собирается из тех же величин, что уже лежат в структурных полях, и +не добавляет к ним ничего: запись остаётся машиночитаемой, а сообщение +существует, чтобы человек мог прочитать строку журнала, не собирая её из шести +колонок. Раньше `entry.Info()` вызывался без аргумента, и logrus записывал +`"msg":""` для каждого запроса — страница системных логов показывала оператору +пустой столбец, точно отражая содержимое файла. + +Читаемость сообщения не является лазейкой для query-строки: в `msg` попадает +только путь, и это закреплено тестом, который проверяет обе половины сразу — +сообщение непустое И не несёт ни токена, ни знака `?`. + Пока логировался `RequestURI`, действующий machine token оседал открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`. Этот файл отдаётся оператору через `ExportLog` и попадает в diagnostics-бандл, то есть секрет утекал наружу в @@ -525,6 +537,78 @@ upstream выберет для нового секрета. Список мар Конфигурация Hysteria остаётся доступной панели **на чтение и на выгрузку**: `GET /config/getHysteria2Config` и `POST /config/exportHysteria2Config`. +### Отзыв доступа к VPN состоит из двух половин + +Панель не управляет жизненным циклом Hysteria, но доступом пиров управляет +целиком — и здесь у неё есть ровно один механизм, требующий обеих половин +официального контракта Hysteria. + +```text +disabled = 1 закрывает БУДУЩИЕ обращения к HTTP-auth +POST /kick завершает УЖЕ УСТАНОВЛЕННУЮ сессию +``` + +Ни одна половина не работает по отдельности. Запись `disabled=1` видит только +выборка в `Hysteria2Auth`, то есть проверяется при следующем подключении; +установленная QUIC-сессия живёт своей жизнью и сама не разрывается. Обратно: +`/kick` завершает сессию, но клиент немедленно переподключается — поэтому +официальная документация Hysteria и требует одновременной блокировки в auth +backend. + +**Порядок обязателен и обратному не подлежит:** + +```text +1. записать disabled = 1 (долговременное состояние) +2. POST /kick по authId пира (разрыв) +``` + +При обратном порядке клиент успевает переподключиться в окне между разрывом и +записью и остаётся на связи с формально отключённым пиром. + +**Неудача второго шага не откатывает первый.** Безопасная половина достигнута; +возвращать пиру полный доступ из-за отказа разрыва нельзя. Операция отвечает +частичным результатом с кодом `peer_disconnect_failed`, панель показывает его +предупреждением и обновляет строку. Повторить операцию можно тем же действием: +условие смотрит на запрошенное состояние, а не на переход из включённого. + +**Отключение и временная блокировка — разные механизмы**, и смешивать их +нельзя: + +| | снимается | назначение | +| --- | --- | --- | +| `disabled` | только руками оператора | отзыв доступа | +| `banned_until` | истекает сам | временная блокировка | + +Поэтому `DisconnectPeers` не пишет в базу вовсе, включение пира не сбрасывает +`banned_until`, а снятие блокировки не включает отключённого пира. + +**Состояние службы по systemd в этом пути не участвует.** `util.Exec` +схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не +удалось» в одну ошибку, поэтому `Hysteria2IsRunning` не является основанием ни +для отказа операции, ни для её пропуска. Ответ даёт само обращение к Traffic +Stats API. + +### Ограничение устройств проверяется fail-closed + +`maxDevices` проверяется по `/online` Traffic Stats API, который возвращает +число экземпляров клиента Hysteria — то есть именно «устройства», а не число +proxy-потоков. + +Недоступность этого API **отклоняет подключение** и пишет запись уровня +`error`. Выбор направления осознанный: запрос авторизации приходит от самой +Hysteria, значит она жива, а её Traffic Stats API слушает loopback внутри того +же процесса — его недоступность является аномалией, а не штатным состоянием. +Обратный выбор молча снимал бы объявленный в панели лимит со всех пиров сразу, +и единственным следом этого была бы строка `warn` в журнале. + +У `maxDevices` есть `min=1`, безлимита не бывает, поэтому такой отказ +затрагивает всех пиров одновременно. Это ожидаемое поведение, а не деградация: +доступность Traffic Stats API входит в install/doctor smoke. + +Путь ОТОБРАЖЕНИЯ остаётся терпимым: дашборд и признак `online` в списке пиров +показывают пустую картину, когда служба остановлена, — это честный ответ на +вопрос «кто сейчас на связи». + ### Что нельзя делать - собирать admin-компонент на target server; @@ -533,6 +617,10 @@ upstream выберет для нового секрета. Список мар - раздувать оркестратор из-за особенностей панели; - использовать HY2XS admin как updater бинаря Hysteria2; - использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API; +- считать `disabled=1` завершённым отзывом доступа без `/kick`; +- откатывать `disabled` из-за неудачи `/kick`; +- писать `banned_until` из пути отключения пира; +- пропускать проверку лимита устройств, когда Traffic Stats API не ответил; - экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля; - выгружать конфиг с секретами в открытом виде. diff --git a/docs/admin/15-ui-contracts.md b/docs/admin/15-ui-contracts.md index 1e2045e..4a76b12 100644 --- a/docs/admin/15-ui-contracts.md +++ b/docs/admin/15-ui-contracts.md @@ -1,6 +1,6 @@ # Контракты панели -Три свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и +Свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и потому ломались молча. Каждое из них закреплено тестом (`tools/test/frontend-*.test.ts`) и гейтом приёмки. @@ -134,7 +134,82 @@ --- -## 4. Атрибуция +## 4. Таблицы журнала + +**Правило.** Колонка журнала объявляет свою ширину: служебные — через `width`, +содержательная — через `min-width`. + +Без этого Element Plus делит доступную ширину между колонками практически +поровну. У журнала колонок три, поэтому уровень и время получали по трети +строки, а сообщение — единственное содержимое журнала — тоже треть. + +**Сообщение переносится, а не обрезается.** У Hysteria в `msg` приезжает +диагностический JSON; строка, обрезанная многоточием, не отвечает ни на один +вопрос, ради которого страницу открыли. + +**Обе страницы журнала построены на одном компоненте** +(`components/LogViewer`). Они были побайтово одинаковы и несли одни и те же три +дефекта в двух экземплярах — ширины, обработку отказа выгрузки и форму ответа. +Собственная `el-table-column` на странице журнала запрещена гейтом приёмки. + +--- + +## 5. Выгрузка файлов + +**Правило.** Сборка ссылки на скачивание существует в панели в единственном +экземпляре — `utils/download.ts`. Единственность проверяется контрактным +тестом по вхождению `createObjectURL`. + +Копий было четыре, и все успели разойтись. Две из них ставили сетевой запрос +ПЕРЕД `try`: + +```ts +const response = await exportApi(...); // отказ сюда не попадает +try { ... } catch (e) { /* empty */ } +``` + +то есть отказ самого запроса не ловился вовсе, а всё внутри глушилось молча: +оператор не получал ни файла, ни причины. Третья падала на `split(...)` при +отсутствующем `Content-Disposition` — и это исключение тоже глушилось. + +**Отказ выгрузки показывает сама страница.** Бинарный ответ не проходит через +общий разбор конверта: у `Blob` нет полей `code` и `errors`, поэтому +перехватчик по нему фразы не даст. + +--- + +## 6. Меню действий над строкой + +**Правило.** Пункты `el-dropdown` объявляют `command`; обработчик — один, на +`el-dropdown`. + +`@click` на каждом пункте не запрещён самим Element Plus, но `command` является +штатным контрактом именно для меню действий, и при нём невозможно добавить +пункт, забыв его подключить. Обе половины проверяются контрактным тестом: +наличие `@command` и отсутствие `@click` на пунктах. + +**Частичный результат операции отличается от отказа кодом.** Отзыв доступа к +VPN состоит из двух половин — записи в базе и разрыва активной сессии, — и +первая может примениться без второй. Панель обязана распознать +`peer_disconnect_failed` по коду, показать его предупреждением, а не ошибкой, и +ОБНОВИТЬ строку: состояние в базе уже изменилось. Показ его как обычной ошибки +подтолкнул бы оператора к выводу, прямо противоположному истине. + +--- + +## 7. Подтверждения + +**Правило.** Отмена подтверждения — это ответ оператора, а не ошибка. + +`ElMessageBox` отклоняет промис при нажатии «Отмена». `await +ElMessageBox.confirm(...)` без разбора отказа оставляет необработанное +отклонение промиса на каждую отмену. Единственный прямой вызов на странице +пиров живёт внутри `confirmAction`, переводящей отмену в обычное `false`; это +закреплено тестом. + +--- + +## 8. Атрибуция Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`, diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 44e94fb..cca67df 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -737,6 +737,8 @@ run_clean_install_acceptance() { run_single_owner_acceptance "$package_dir" run_secret_channel_acceptance run_atomic_import_acceptance + run_access_revocation_acceptance + run_observability_acceptance run_legacy_account_acceptance run_scheduler_lifecycle_acceptance run_dead_config_acceptance @@ -1525,6 +1527,166 @@ run_atomic_import_acceptance() { 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 -- ' { 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(" { + const viewer = readSource("components", "LogViewer", "index.vue"); + + // Без width/min-width Element Plus делит ширину практически поровну, и + // сообщение — единственное содержимое журнала — получает треть строки. + const columns = [...viewer.matchAll(/|>)/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(//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"); + } + }); +});