fix(admin): достроить вторые половины отзыва доступа, лимита и журнала
Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.
Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.
Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.
Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.
Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.
Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
This commit is contained in:
+180
@@ -54,6 +54,186 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
||||
действовал, потому что цвет был вписан в сами ассеты. Ветка «сессия истекла,
|
||||
войдите заново» существовала — и была недостижима сразу по двум причинам.
|
||||
|
||||
Десятый проход — разбор кода после девятого, на коммите `c0a43ae9`, со сверкой
|
||||
Hysteria-интеграции с официальной документацией Hysteria 2. Тема прохода:
|
||||
операции, у которых есть только одна из двух необходимых половин. Отключение
|
||||
пира записывало запрет и не разрывало его сессию. Ограничение устройств
|
||||
проверялось, пока отвечал Traffic Stats API, и молча снималось, когда он не
|
||||
отвечал. Журнал собирал структурные поля и записывал пустое сообщение. Разбор
|
||||
задокументирован в
|
||||
[docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md](docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md).
|
||||
|
||||
### Исправлено — отзыв доступа к VPN
|
||||
|
||||
- **Отключение пира не отзывало доступ.** Запись `disabled=1` видит только
|
||||
выборка в `Hysteria2Auth`, то есть она закрывает БУДУЩИЕ обращения к
|
||||
HTTP-auth; установленная QUIC-сессия живёт своей жизнью и сама не
|
||||
разрывается. После «Отключить» пир продолжал пользоваться доступом сколько
|
||||
угодно долго, пока не переподключался по своей воле, — а панель показывала
|
||||
его отключённым. Официальная документация Hysteria описывает `/kick` и
|
||||
блокировку в auth backend как пару: по отдельности не работает ни одна
|
||||
половина.
|
||||
|
||||
Появился `service.DisconnectPeers` — только официальный Traffic Stats
|
||||
`/kick`, без единой записи в базу. Прежний `Hysteria2Kick` вместе с разрывом
|
||||
проставлял `banned_until`, поэтому воспользоваться им для отключения было
|
||||
нельзя: операция записала бы заодно временную блокировку — другой механизм с
|
||||
другим сроком жизни.
|
||||
|
||||
Порядок обратному не подлежит: сначала долговременная запись, затем разрыв.
|
||||
При обратном клиент успевает переподключиться в окне между `/kick` и записью.
|
||||
Порядок доказывается снимком состояния базы В МОМЕНТ прихода `/kick` — после
|
||||
операции оба шага уже выполнены и проверять там нечего.
|
||||
|
||||
- **Неудача разрыва не откатывает запрет.** Безопасная половина достигнута, и
|
||||
возвращать пиру полный доступ из-за отказа второго шага нельзя. Частичный
|
||||
результат сообщается отдельным кодом `peer_disconnect_failed`: без него
|
||||
оператор прочитал бы обычную ошибку как «не сработало, состояние прежнее» —
|
||||
вывод, прямо противоположный истине. Панель показывает его предупреждением и
|
||||
обновляет строку.
|
||||
|
||||
- **Временная блокировка отчитывалась отказом, будучи применённой.** `KickPeer`
|
||||
писал `banned_until`, затем звал `Hysteria2Kick`, который писал то же
|
||||
значение второй раз, а до этого мог отказать на проверке состояния службы —
|
||||
не сделав ничего, но уже после первой записи. Обе операции переведены на один
|
||||
примитив.
|
||||
|
||||
- **`Hysteria2IsRunning` убран с путей принятия решений.** `util.Exec`
|
||||
схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не
|
||||
удалось» в одну ошибку, поэтому его ответ не является основанием ни для
|
||||
отказа операции, ни для её пропуска. Значению осталось место в отображении,
|
||||
где ошибочное «выключено» стоит одной неверной плашки.
|
||||
|
||||
- **Ограничение устройств стало fail-closed.** Отказ `/online` обрабатывался
|
||||
возвратом успеха авторизации, то есть недоступность внутреннего `127.0.0.1`
|
||||
превращала объявленный в панели «Лимит устройств: 3» в безлимит; следом была
|
||||
только строка `warn` в журнале.
|
||||
|
||||
Вторая половина той же дыры была тише: `Hysteria2Online` отдавал ПУСТУЮ КАРТУ
|
||||
БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», — и лимит переставал
|
||||
проверяться вообще без единой записи. Пути разделены по назначению: терпимый
|
||||
для отображения, строгий для решения о доступе.
|
||||
|
||||
Направление выбрано осознанно: запрос авторизации приходит ОТ Hysteria,
|
||||
значит она жива, а её Traffic Stats API слушает loopback внутри того же
|
||||
процесса. У `maxDevices` есть `min=1`, безлимита не бывает, поэтому такой
|
||||
отказ затрагивает всех пиров сразу — и пишется уровнем `error`.
|
||||
|
||||
- **Секрет Traffic Stats API читался без проверки на nil** прямо на пути
|
||||
machine-auth, то есть при каждом подключении пира: строка в таблице `config`
|
||||
без значения уронила бы админку паникой на разыменовании.
|
||||
|
||||
- **Ошибка публичного порта больше не называет транспорт.** Публичный endpoint
|
||||
Hysteria — UDP/QUIC, а сообщение утверждало `must be a valid TCP port`.
|
||||
|
||||
### Исправлено — журнал и страницы, которые его показывают
|
||||
|
||||
- **Журнал запросов писал пустой `msg`.** `entry.Info()`, `entry.Warn()` и
|
||||
`entry.Error()` вызывались без аргумента сообщения, и logrus честно
|
||||
записывал `"msg":""` для каждого HTTP-запроса. Пустой столбец на странице
|
||||
системных логов был точным отражением того, что записал backend, а не
|
||||
рассогласованием модели отображения с форматом файла.
|
||||
|
||||
`middleware.RequestLogMessage` собирает строку из тех же величин, что уже
|
||||
лежат в структурных полях. Query-строка в неё не попадает: контракт
|
||||
безопасности сильнее удобства чтения, и тест проверяет обе половины сразу —
|
||||
сообщение непустое И не несёт ни machine token, ни знака `?`.
|
||||
|
||||
- **Страница системных логов не работала, пока нет файла журнала.** Эта ветка
|
||||
отвечала голым массивом вместо `{records, total}`, панель читала
|
||||
`data.records` и передавала `undefined` в таблицу. То есть на
|
||||
свежепоставленном хосте — ровно в сценарии приёмки чистой установки —
|
||||
страница была сломана целиком.
|
||||
|
||||
- **Одна битая строка ломала весь ответ.** При неразбираемой записи
|
||||
выполнялись `vo.Fail(...)` И `continue`: ответ уже записан в поток, цикл идёт
|
||||
дальше, а в конце безусловно выполняется `vo.Success`. Клиент получал два
|
||||
JSON-документа подряд. Строка при этом бьётся штатно — lumberjack ротирует
|
||||
файл, и обрыв последней записи на границе ротации обычен. Теперь такая строка
|
||||
пропускается: остальные записи прочитаны и полезны.
|
||||
|
||||
### Исправлено — панель оператора, второй заход
|
||||
|
||||
- **Быстрого включения/отключения пира в меню не было вовсе.** Сменить
|
||||
состояние можно было только через форму изменения, хотя это самое частое
|
||||
действие оператора.
|
||||
|
||||
- **Меню действий переведено на `command`.** `@click` на каждом
|
||||
`el-dropdown-item` заменён штатным контрактом `el-dropdown`: команда
|
||||
приходит в одно место, и добавить пункт, забыв его подключить, становится
|
||||
невозможно.
|
||||
|
||||
- **Колонки таблиц журнала не задавали ширину.** Без `width`/`min-width`
|
||||
Element Plus делит доступную ширину практически поровну, и сообщение —
|
||||
единственное содержимое журнала — получало треть строки. Служебные колонки
|
||||
зафиксированы, колонка сообщения растягивается за счёт остатка и
|
||||
переносится, а не обрезается многоточием: у Hysteria в `msg` приезжает
|
||||
диагностический JSON.
|
||||
|
||||
- **Обе страницы журнала построены на общем `LogViewer`.** Они были побайтово
|
||||
одинаковы и несли одни и те же дефекты в двух экземплярах; починка в двух
|
||||
местах разошлась бы снова.
|
||||
|
||||
- **Отказ выгрузки журнала не ловился и глушился.** Сетевой запрос стоял ПЕРЕД
|
||||
`try`, поэтому его отказ этим `catch` не ловился вовсе, а сам `catch` был
|
||||
пуст. Оператор нажимал «Экспорт» и не получал ни файла, ни причины.
|
||||
|
||||
- **Сборка ссылки на скачивание сведена в `utils/download.ts`.** Копий было
|
||||
четыре, и все успели разойтись: две ставили запрос вне `try`, третья падала
|
||||
на `split(...)` при отсутствующем `Content-Disposition` — и это исключение
|
||||
тоже глушилось. Четвёртую копию (выгрузка конфига Hysteria) нашёл
|
||||
контрактный тест, потребовавший единственности `createObjectURL`.
|
||||
|
||||
- **Ширина всплывающей подсказки ограничена штатным `popper-style`.** Без
|
||||
ограничения длинный перевод получал естественную ширину popper и
|
||||
растягивался почти на весь экран одной строкой.
|
||||
|
||||
- **Срок временной блокировки называется оператору.** `Date.now() + час` был
|
||||
зашит в обработчик, не подтверждался и не сообщался ни до, ни после; ключи
|
||||
локализации для него существовали и были мёртвыми. Пункты переименованы так,
|
||||
чтобы «временно заблокировать» не путалось с «отключить пир».
|
||||
|
||||
- **Отмена подтверждения перестала быть ошибкой.** `ElMessageBox` отклоняет
|
||||
промис при нажатии «Отмена», и `await ElMessageBox.confirm(...)` без разбора
|
||||
отказа оставлял необработанное отклонение промиса — в четырёх местах страницы
|
||||
пиров и ещё одном в верхней панели.
|
||||
|
||||
- **Подсказка имени пира описывала не действующее правило.** Она обещала
|
||||
«латиница, цифры и дефис», тогда как `service.IsValidPeerName` принимает
|
||||
`a-z A-Z 0-9 !@#$%^&*()_+-=`: подсказка осталась от правила, действовавшего
|
||||
до EX-03, и оператор не имел причин пробовать разрешённые символы. Тест
|
||||
читает набор из серверной константы `PeerNameCharset`.
|
||||
|
||||
- **Типы полей журнала в панели расходились с сервером** (`latencyTime` и
|
||||
`statusCode` объявлены строками при `int64` на сервере). Пока колонки не
|
||||
отображались, расхождение было безвредным.
|
||||
|
||||
- **Диагностика слоя proxy сообщала `err: <nil>` именно при отказе API.**
|
||||
Ветка `err != nil || resp.StatusCode != http.StatusOK` печатала `err`, то
|
||||
есть при ответе Hysteria с кодом, отличным от 200, в журнал уезжала запись
|
||||
без единого факта. Различать эти два случая стало обязательно: на этом пути
|
||||
теперь стоит отзыв доступа.
|
||||
|
||||
- **Отказ показывался дважды, а транспортный — ни разу.** Действия строки пира
|
||||
не объявляли `skipErrorToast`, поэтому после появления собственных сообщений
|
||||
отказ показывался и перехватчиком, и страницей: у частичного результата
|
||||
отзыва доступа это давало предупреждение «состояние применено наполовину» и
|
||||
рядом ошибку — два противоречащих сообщения об одном событии.
|
||||
|
||||
Обратная половина существовала и раньше: флаг гасил не только отказ API, но и
|
||||
ТРАНСПОРТНЫЙ отказ, при том что все объявлявшие его страницы писали в своих
|
||||
обработчиках «транспортный отказ уже показан общим перехватчиком» и молчали.
|
||||
Обрыв соединения при сохранении или удалении пира не показывал оператору
|
||||
ничего. Флаг отнесён только к отказу API — тому, у которого есть конверт с
|
||||
`code` и `errors`, то есть есть что разбирать.
|
||||
|
||||
- **Из globals ESLint убраны `DialogType` и `OptionType`** — их нет ни в
|
||||
`src/types/global.d.ts`, ни где-либо в панели; список приведён к
|
||||
фактическому содержимому файла объявлений. Добавлены `IdDto`, `BaseDto` и
|
||||
`PageVo`, объявленные там на самом деле: `no-undef` не знает о
|
||||
`declare global`, и отсутствующее в списке имя падало ошибкой линтера на
|
||||
совершенно корректном коде.
|
||||
|
||||
### Исправлено — панель оператора
|
||||
|
||||
- **Необязательный секрет пира был фактически обязателен.** Панель обещала
|
||||
|
||||
+33
-6
@@ -17,10 +17,20 @@ func LogSystem(c *gin.Context) {
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
// Собственный журнал санитизируется так же, как чужой.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// Форма ответа страницы системных логов.
|
||||
//
|
||||
// Панель читает `data.records` и передаёт результат прямо в `:data` таблицы.
|
||||
// Поэтому ответ обязан нести records/total на КАЖДОЙ ветке — иначе таблица
|
||||
// получает undefined и страница ломается целиком.
|
||||
|
||||
type logPageEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Data *struct {
|
||||
Records []map[string]any `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// requestSystemLog выполняет запрос к странице системных логов, подставив
|
||||
// вместо журнала переданное содержимое. Пустой content означает «файла нет».
|
||||
func requestSystemLog(t *testing.T, content *string) (logPageEnvelope, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "hy2xs-admin.log")
|
||||
if content != nil {
|
||||
if err := os.WriteFile(path, []byte(*content), 0o600); err != nil {
|
||||
t.Fatalf("не удалось записать тестовый журнал: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
previous := constant.SystemLogPath
|
||||
constant.SystemLogPath = path
|
||||
t.Cleanup(func() { constant.SystemLogPath = previous })
|
||||
|
||||
engine := gin.New()
|
||||
engine.GET("/log/logSystem", LogSystem)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/log/logSystem?numLine=100", nil))
|
||||
|
||||
raw := recorder.Body.String()
|
||||
var envelope logPageEnvelope
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
|
||||
t.Fatalf("ответ не разбирается как один JSON-документ: %v\nтело: %s", err, raw)
|
||||
}
|
||||
return envelope, raw
|
||||
}
|
||||
|
||||
// Регрессия LOG-04. Ветка «файла ещё нет» отвечала ГОЛЫМ МАССИВОМ:
|
||||
//
|
||||
// vo.Success(logSystemVos, c)
|
||||
//
|
||||
// Панель получала `data.records === undefined` и передавала undefined в
|
||||
// таблицу. То есть на свежепоставленном хосте — до первой записи в журнал —
|
||||
// страница системных логов не работала вовсе, а это ровно тот сценарий,
|
||||
// который проверяется на приёмке каждой чистой установки.
|
||||
func TestLogSystemKeepsPageShapeWhenFileIsMissing(t *testing.T) {
|
||||
envelope, raw := requestSystemLog(t, nil)
|
||||
|
||||
if envelope.Type != "ok" {
|
||||
t.Fatalf("отсутствие журнала признано отказом: %s", raw)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("ответ не несёт объекта страницы: %s", raw)
|
||||
}
|
||||
if envelope.Data.Records == nil {
|
||||
t.Fatalf("ответ не несёт поля records: %s", raw)
|
||||
}
|
||||
if len(envelope.Data.Records) != 0 || envelope.Data.Total != 0 {
|
||||
t.Fatalf("ожидалась пустая страница, получено %+v", *envelope.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия LOG-05. При неразбираемой строке вызывался vo.Fail(...) и следом
|
||||
// `continue`: ответ уже записан в поток, цикл идёт дальше, в конце безусловно
|
||||
// выполняется vo.Success. Клиент получал два JSON-документа подряд — то есть
|
||||
// невалидный ответ, — и одной битой строки хватало, чтобы страница перестала
|
||||
// открываться целиком.
|
||||
//
|
||||
// Строка бьётся штатно: lumberjack ротирует файл, и обрыв последней записи на
|
||||
// границе ротации — обычное событие.
|
||||
func TestLogSystemSkipsBrokenLineWithoutBreakingResponse(t *testing.T) {
|
||||
content := `{"level":"info","msg":"GET /api/peers → 200 (7 ms)","time":"2026-09-01 10:00:00"}
|
||||
{"level":"info","msg":"обрыв записи на границе ротации
|
||||
{"level":"warning","msg":"PATCH /api/peers/12 → 400 (4 ms)","time":"2026-09-01 10:00:01"}
|
||||
`
|
||||
envelope, raw := requestSystemLog(t, &content)
|
||||
|
||||
if envelope.Type != "ok" {
|
||||
t.Fatalf("битая строка отказала всей выдаче: %s", raw)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("ответ не несёт объекта страницы: %s", raw)
|
||||
}
|
||||
if len(envelope.Data.Records) != 2 {
|
||||
t.Fatalf("ожидались две читаемые записи, получено %d: %+v", len(envelope.Data.Records), envelope.Data.Records)
|
||||
}
|
||||
for _, record := range envelope.Data.Records {
|
||||
if message, _ := record["msg"].(string); message == "" {
|
||||
t.Errorf("запись пришла с пустым msg: %+v", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Читаемый журнал доезжает до панели в той же форме.
|
||||
func TestLogSystemReturnsRecordsForReadableFile(t *testing.T) {
|
||||
content := `{"level":"info","msg":"GET /api/peers → 200 (7 ms)","reqMethod":"GET","reqPath":"/api/peers","statusCode":200,"latencyTime":7,"time":"2026-09-01 10:00:00"}
|
||||
`
|
||||
envelope, raw := requestSystemLog(t, &content)
|
||||
|
||||
if envelope.Data == nil || len(envelope.Data.Records) != 1 {
|
||||
t.Fatalf("читаемая запись не доехала до панели: %s", raw)
|
||||
}
|
||||
record := envelope.Data.Records[0]
|
||||
if message, _ := record["msg"].(string); message == "" {
|
||||
t.Fatalf("msg потерян при отдаче: %+v", record)
|
||||
}
|
||||
if path, _ := record["reqPath"].(string); path != "/api/peers" {
|
||||
t.Fatalf("структурные поля не доехали: %+v", record)
|
||||
}
|
||||
}
|
||||
@@ -285,8 +285,12 @@ func KickPeer(c *gin.Context) {
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,10 +35,20 @@ export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Действия над строкой пира сообщают свой исход сами (см. 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="page-toolbar">
|
||||
<el-form :model="queryParams" :inline="true">
|
||||
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
||||
<el-select
|
||||
v-model="queryParams.numLine"
|
||||
style="width: 200px"
|
||||
@change="load"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in LINE_OPTIONS"
|
||||
:key="option"
|
||||
:label="String(option)"
|
||||
:value="option"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="export">
|
||||
<el-button :loading="exporting" @click="handleExport">
|
||||
<template #icon>
|
||||
<i-ep-download />
|
||||
</template>
|
||||
{{ $t("common.export") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item prop="refresh">
|
||||
<el-button @click="load">
|
||||
<template #icon>
|
||||
<i-ep-refresh />
|
||||
</template>
|
||||
{{ $t("common.refresh") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="records" class="log-table">
|
||||
<!--
|
||||
Ширины заданы явно и по назначению колонки.
|
||||
|
||||
Без width/min-width Element Plus делит доступную ширину между
|
||||
колонками практически поровну: уровень и время получали по трети
|
||||
строки, а сообщение — тоже треть, хотя именно оно и является
|
||||
содержимым журнала. У Hysteria в msg приезжает диагностический JSON,
|
||||
поэтому колонка сообщения растягивается за счёт остатка, а служебные
|
||||
колонки зафиксированы.
|
||||
-->
|
||||
<el-table-column
|
||||
prop="level"
|
||||
label="level"
|
||||
width="110"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="msg" label="msg" min-width="640" align="left">
|
||||
<template #default="scope">
|
||||
<!--
|
||||
Сообщение ПЕРЕНОСИТСЯ, а не обрезается многоточием: у Hysteria
|
||||
это диагностический JSON, и обрезанная строка не отвечает ни на
|
||||
один вопрос, ради которого страницу открыли.
|
||||
-->
|
||||
<span class="log-message">{{ scope.row.msg }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="time" label="time" width="200" align="center" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Общая страница журнала: системного и Hysteria.
|
||||
*
|
||||
* Обе страницы были побайтово одинаковы, кроме вызываемого API, и обе несли
|
||||
* одни и те же три дефекта: колонки без ширин, экспорт с сетевым запросом ВНЕ
|
||||
* try и пустой catch, глушивший всё остальное. Починка в двух местах разошлась
|
||||
* бы снова — как разошлись форма ответа и обработка ошибок до этого.
|
||||
*
|
||||
* Различие между страницами вынесено в параметры: чем читать и что выгружать.
|
||||
*/
|
||||
import { AxiosPromise, AxiosResponse } from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { exportLogApi } from "@/api/log";
|
||||
import { LogDto } from "@/api/log/types";
|
||||
import { saveResponseAsFile } from "@/utils/download";
|
||||
|
||||
/** Строка журнала в объёме, который показывает таблица. */
|
||||
interface LogRow {
|
||||
level: string;
|
||||
msg: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Чтение страницы журнала. */
|
||||
fetchLogs: (query: LogDto) => AxiosPromise<PageVo<LogRow>>;
|
||||
/** Значение option для выгрузки: 0 — журнал админки, 1 — журнал Hysteria. */
|
||||
exportOption: number;
|
||||
/** Имя файла, если сервер его не назвал. */
|
||||
exportFileName: string;
|
||||
}>();
|
||||
|
||||
const LINE_OPTIONS = [100, 200, 300];
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const records = ref<LogRow[]>([]);
|
||||
const queryParams = reactive<LogDto>({ numLine: LINE_OPTIONS[0] });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await props.fetchLogs(queryParams);
|
||||
// Ответ обязан нести records на любой ветке — это контракт LogSystemPage.
|
||||
// Запасное значение здесь стоит не вместо контракта, а чтобы отказ формы
|
||||
// ответа не превращался в исключение внутри таблицы.
|
||||
records.value = data?.records ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выгрузка журнала.
|
||||
*
|
||||
* Сетевой запрос находится ВНУТРИ try. Раньше он стоял перед ним, поэтому его
|
||||
* отказ не ловился этим catch вовсе, а сам catch был пуст: оператор не получал
|
||||
* ни файла, ни причины.
|
||||
*
|
||||
* Отказ показывается ОДИН раз. Выгрузка приходит бинарным потоком и не
|
||||
* проходит через общий разбор конверта — у Blob нет полей code и errors, —
|
||||
* поэтому фразу здесь даёт сама страница.
|
||||
*/
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const response: AxiosResponse = await exportLogApi({
|
||||
option: props.exportOption,
|
||||
});
|
||||
saveResponseAsFile(response, props.exportFileName);
|
||||
ElMessage.success(t("common.downloadSuccess"));
|
||||
} catch {
|
||||
ElMessage.error(t("log.exportFailed"));
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-message {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
font-family: var(--el-font-family-mono, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -154,12 +154,18 @@ export default {
|
||||
credential_format: "“{field}”: contains characters that are not allowed",
|
||||
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",
|
||||
|
||||
@@ -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: "Назад",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+1
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
/**
|
||||
* Скачивание бинарного ответа API одним способом на всю панель.
|
||||
*
|
||||
* Раньше эта последовательность — Blob, createObjectURL, временная ссылка,
|
||||
* клик, revokeObjectURL — была скопирована в трёх местах, и все три копии
|
||||
* успели разойтись: страница пиров разбирала Content-Disposition с запасным
|
||||
* именем файла, а обе страницы логов падали на `undefined.split(...)`, если
|
||||
* заголовка не оказалось.
|
||||
*
|
||||
* Хуже расхождения был порядок на страницах логов:
|
||||
*
|
||||
* const response = await exportLogApi(...); // <- запрос ВНЕ try
|
||||
* try { ... } catch (e) { } // <- и пустой catch
|
||||
*
|
||||
* Сетевой запрос стоял ПЕРЕД try, поэтому его отказ этим catch не ловился
|
||||
* вовсе, а всё, что происходило внутри, глушилось молча. Оператор нажимал
|
||||
* «Экспорт» и не получал ни файла, ни объяснения.
|
||||
*/
|
||||
|
||||
/** Имя файла по умолчанию, если сервер его не назвал. */
|
||||
const CONTENT_DISPOSITION_PREFIX = "attachment; filename=";
|
||||
|
||||
/**
|
||||
* Имя файла из заголовка ответа.
|
||||
*
|
||||
* Отсутствующий или незнакомый заголовок — не отказ: у скачивания есть
|
||||
* разумное запасное имя, и терять из-за формата заголовка уже полученный файл
|
||||
* незачем.
|
||||
*/
|
||||
export function fileNameFromResponse(
|
||||
response: AxiosResponse,
|
||||
fallback: string
|
||||
): string {
|
||||
const header = response.headers?.["content-disposition"];
|
||||
if (typeof header !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
const index = header.indexOf(CONTENT_DISPOSITION_PREFIX);
|
||||
if (index < 0) {
|
||||
return fallback;
|
||||
}
|
||||
const name = header.slice(index + CONTENT_DISPOSITION_PREFIX.length).trim();
|
||||
return name === "" ? fallback : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отдаёт бинарный ответ пользователю как файл.
|
||||
*
|
||||
* Временная ссылка УДАЛЯЕТСЯ из документа, а objectURL освобождается в
|
||||
* `finally`. Прежние копии оставляли `<a>` в body навсегда — по одному
|
||||
* элементу на каждое нажатие «Экспорт» за всю жизнь вкладки.
|
||||
*/
|
||||
export function saveResponseAsFile(
|
||||
response: AxiosResponse,
|
||||
fallbackFileName: string
|
||||
): void {
|
||||
const blob = new Blob([response.data], { type: "application/octet-stream" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
|
||||
try {
|
||||
anchor.href = url;
|
||||
anchor.download = fileNameFromResponse(response, fallbackFileName);
|
||||
anchor.style.display = "none";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
} finally {
|
||||
anchor.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,13 @@ const t = i18n.global.t;
|
||||
* Запрос может отказаться от общего тоста, если показывает причину сам.
|
||||
*
|
||||
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
|
||||
* второй сигнал тостом там только шумит.
|
||||
* второй сигнал тостом там только шумит. Так же поступают действия над строкой
|
||||
* пира: отзыв доступа умеет завершиться частично, и такой исход показывается
|
||||
* предупреждением, а не ошибкой.
|
||||
*
|
||||
* Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code`
|
||||
* и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего,
|
||||
* и молчание о нём означало бы операцию, не происходящую без объяснений.
|
||||
*/
|
||||
declare module "axios" {
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,109 +1,24 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="page-toolbar">
|
||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
||||
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
||||
<el-select
|
||||
v-model="queryParams.numLine"
|
||||
style="width: 200px"
|
||||
@change="setRecords"
|
||||
>
|
||||
<el-option label="100" value="100" />
|
||||
<el-option label="200" value="200" />
|
||||
<el-option label="300" value="300" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="export">
|
||||
<el-button @click="handleExport">
|
||||
<template #icon>
|
||||
<i-ep-download />
|
||||
</template>
|
||||
{{ $t("common.export") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item prop="refresh">
|
||||
<el-button @click="setRecords">
|
||||
<template #icon>
|
||||
<i-ep-refresh />
|
||||
</template>
|
||||
{{ $t("common.refresh") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="records">
|
||||
<el-table-column
|
||||
key="level"
|
||||
label="level"
|
||||
align="center"
|
||||
prop="level"
|
||||
/>
|
||||
<el-table-column key="msg" label="msg" align="center" prop="msg" />
|
||||
<el-table-column key="time" label="time" align="center" prop="time" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
<log-viewer
|
||||
:fetch-logs="logHysteria2Api"
|
||||
:export-option="1"
|
||||
export-file-name="hysteria2.log"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "index",
|
||||
name: "LogHysteria",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LogDto, LogHysteria2Vo } from "@/api/log/types";
|
||||
import { exportLogApi, logHysteria2Api } from "@/api/log";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
total: 0,
|
||||
records: [] as LogHysteria2Vo[],
|
||||
queryParams: {
|
||||
numLine: 100,
|
||||
} as LogDto,
|
||||
});
|
||||
|
||||
const { loading, records, queryParams } = toRefs(state);
|
||||
|
||||
const setRecords = async () => {
|
||||
try {
|
||||
state.loading = true;
|
||||
const { data } = await logHysteria2Api(state.queryParams);
|
||||
state.records = data.records;
|
||||
state.total = data.total;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
let response = await exportLogApi({ option: 1 });
|
||||
try {
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
let url = window.URL.createObjectURL(blob);
|
||||
let a = document.createElement("a");
|
||||
document.body.appendChild(a);
|
||||
a.href = url;
|
||||
let dis = response.headers["content-disposition"];
|
||||
a.download = dis.split("attachment; filename=")[1];
|
||||
// Имитация клика для скачивания
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
ElMessage.success(t("common.success"));
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setRecords();
|
||||
});
|
||||
/**
|
||||
* Журнал Hysteria.
|
||||
*
|
||||
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer — см.
|
||||
* страницу системного журнала.
|
||||
*/
|
||||
import LogViewer from "@/components/LogViewer/index.vue";
|
||||
import { logHysteria2Api } from "@/api/log";
|
||||
</script>
|
||||
|
||||
@@ -1,109 +1,25 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="page-toolbar">
|
||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
||||
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
||||
<el-select
|
||||
v-model="queryParams.numLine"
|
||||
style="width: 200px"
|
||||
@change="setRecords"
|
||||
>
|
||||
<el-option label="100" value="100" />
|
||||
<el-option label="200" value="200" />
|
||||
<el-option label="300" value="300" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="export">
|
||||
<el-button @click="handleExport">
|
||||
<template #icon>
|
||||
<i-ep-download />
|
||||
</template>
|
||||
{{ $t("common.export") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item prop="refresh">
|
||||
<el-button @click="setRecords">
|
||||
<template #icon>
|
||||
<i-ep-refresh />
|
||||
</template>
|
||||
{{ $t("common.refresh") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="records">
|
||||
<el-table-column
|
||||
key="level"
|
||||
label="level"
|
||||
align="center"
|
||||
prop="level"
|
||||
/>
|
||||
<el-table-column key="msg" label="msg" align="center" prop="msg" />
|
||||
<el-table-column key="time" label="time" align="center" prop="time" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
<log-viewer
|
||||
:fetch-logs="logSystemApi"
|
||||
:export-option="0"
|
||||
export-file-name="hy2xs-admin.log"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "index",
|
||||
name: "LogSystem",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LogDto, LogSystemVo } from "@/api/log/types";
|
||||
import { exportLogApi, logSystemApi } from "@/api/log";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
total: 0,
|
||||
records: [] as LogSystemVo[],
|
||||
queryParams: {
|
||||
numLine: 100,
|
||||
} as LogDto,
|
||||
});
|
||||
|
||||
const { loading, records, queryParams } = toRefs(state);
|
||||
|
||||
const setRecords = async () => {
|
||||
try {
|
||||
state.loading = true;
|
||||
const { data } = await logSystemApi(state.queryParams);
|
||||
state.records = data.records;
|
||||
state.total = data.total;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
let response = await exportLogApi({ option: 0 });
|
||||
try {
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
let url = window.URL.createObjectURL(blob);
|
||||
let a = document.createElement("a");
|
||||
document.body.appendChild(a);
|
||||
a.href = url;
|
||||
let dis = response.headers["content-disposition"];
|
||||
a.download = dis.split("attachment; filename=")[1];
|
||||
// Имитация клика для скачивания
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
ElMessage.success(t("common.success"));
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setRecords();
|
||||
});
|
||||
/**
|
||||
* Журнал админки.
|
||||
*
|
||||
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer: страница
|
||||
* системного журнала и страница журнала Hysteria были побайтово одинаковы и
|
||||
* несли одни и те же дефекты в двух экземплярах.
|
||||
*/
|
||||
import LogViewer from "@/components/LogViewer/index.vue";
|
||||
import { logSystemApi } from "@/api/log";
|
||||
</script>
|
||||
|
||||
@@ -30,7 +30,17 @@
|
||||
>
|
||||
<el-button>{{ $t("common.import") }}</el-button>
|
||||
</el-upload>
|
||||
<el-tooltip :content="$t('peer.exportSettingsHint')" placement="bottom">
|
||||
<!--
|
||||
Ширина всплывающей подсказки ограничена штатным popper-style, а не
|
||||
глобальным CSS. Без ограничения popper берёт естественную ширину
|
||||
содержимого, и длинная подсказка растягивалась почти на весь экран
|
||||
одной строкой.
|
||||
-->
|
||||
<el-tooltip
|
||||
:content="$t('peer.exportSettingsHint')"
|
||||
placement="bottom"
|
||||
:popper-style="TOOLTIP_POPPER_STYLE"
|
||||
>
|
||||
<el-button class="ml-2" @click="handleExport">{{
|
||||
$t("peer.exportSettings")
|
||||
}}</el-button>
|
||||
@@ -132,35 +142,53 @@
|
||||
@click="copyUri(peerRow(scope.row))"
|
||||
>{{ $t("peer.copyUri") }}</el-button
|
||||
>
|
||||
<el-dropdown trigger="click" placement="bottom-end">
|
||||
<!--
|
||||
Действия объявлены через command, а не обработчиками click на
|
||||
каждом пункте. Это штатный API el-dropdown для меню действий:
|
||||
команда приходит в одно место, и добавить пункт, забыв его
|
||||
подключить, здесь уже нельзя.
|
||||
-->
|
||||
<el-dropdown
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
@command="
|
||||
(command: PeerCommand) =>
|
||||
runCommand(command, peerRow(scope.row))
|
||||
"
|
||||
>
|
||||
<el-button text circle class="peer-more-btn" @click.stop>
|
||||
<i-ep-more-filled />
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="showQr(peerRow(scope.row))">{{
|
||||
<el-dropdown-item command="qr">{{
|
||||
$t("peer.clientQr")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
@click="handleUpdate(peerRow(scope.row))"
|
||||
>{{ $t("common.edit") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item
|
||||
@click="handleResetTraffic(peerRow(scope.row))"
|
||||
>{{ $t("common.resetTraffic") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item @click="handleKick(peerRow(scope.row))">{{
|
||||
<el-dropdown-item command="edit">{{
|
||||
$t("common.edit")
|
||||
}}</el-dropdown-item>
|
||||
<!--
|
||||
Быстрое включение/отключение. Пункта здесь не было вовсе:
|
||||
сменить состояние можно было только через форму
|
||||
изменения, хотя это самое частое действие оператора.
|
||||
-->
|
||||
<el-dropdown-item command="toggle-disabled">{{
|
||||
scope.row.disabled === 1
|
||||
? $t("peer.enablePeer")
|
||||
: $t("peer.disablePeer")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item command="reset-traffic">{{
|
||||
$t("common.resetTraffic")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item divided command="kick">{{
|
||||
$t("peer.kick")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
@click="handleReleaseKick(peerRow(scope.row))"
|
||||
>{{ $t("peer.releaseKick") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item
|
||||
divided
|
||||
@click="handleDelete(peerRow(scope.row))"
|
||||
>{{ $t("common.delete") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item command="release-kick">{{
|
||||
$t("peer.releaseKick")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item divided command="delete">{{
|
||||
$t("common.delete")
|
||||
}}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
@@ -214,7 +242,10 @@
|
||||
/>
|
||||
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.quota')" :error="serverErrors.quotaBytes">
|
||||
<el-form-item
|
||||
:label="$t('peer.quota')"
|
||||
:error="serverErrors.quotaBytes"
|
||||
>
|
||||
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
|
||||
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
|
||||
</el-form-item>
|
||||
@@ -231,7 +262,9 @@
|
||||
:error="serverErrors.maxDevices"
|
||||
><el-input-number v-model="dataForm.maxDevices" :min="1"
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.disabled')" :error="serverErrors.disabled"
|
||||
<el-form-item
|
||||
:label="$t('peer.disabled')"
|
||||
:error="serverErrors.disabled"
|
||||
><el-switch v-model="disabledBool"
|
||||
/></el-form-item>
|
||||
</el-form>
|
||||
@@ -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<string, unknown> = { type: "warning" }
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await ElMessageBox.confirm(message, title, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отказ операции над пиром, показанный оператору один раз.
|
||||
*
|
||||
* Отдельно разбирается ЧАСТИЧНЫЙ результат отзыва доступа: запрет новых
|
||||
* подключений в базу записан, а активную сессию завершить не удалось. Это не
|
||||
* отказ операции, и показывать его как ошибку означало бы подтолкнуть
|
||||
* оператора к неверному выводу «не сработало, состояние прежнее». Список при
|
||||
* этом обновляется: строка обязана показать уже применённое состояние.
|
||||
*/
|
||||
function reportPeerActionError(error: unknown): void {
|
||||
if (!isApiError(error)) {
|
||||
// Транспортный отказ уже показан общим перехватчиком.
|
||||
return;
|
||||
}
|
||||
if (error.hasCode(ERR_CODE.peerDisconnectFailed)) {
|
||||
ElMessage.warning(describeApiError(error));
|
||||
return;
|
||||
}
|
||||
ElMessage.error(describeApiError(error));
|
||||
}
|
||||
|
||||
async function handleDelete(row: PeerVo) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+28
-3
@@ -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=<machine token> при каждом подключении
|
||||
// пира; журнал отдаётся оператору через ExportLog и уезжает в
|
||||
// diagnostics-бандл. Логируется путь, а из query — только имена параметров,
|
||||
// отдельным полем (см. requestQueryKeys).
|
||||
func RequestLogMessage(method string, path string, statusCode int, latency time.Duration) string {
|
||||
return fmt.Sprintf("%s %s → %d (%d ms)", method, path, statusCode, latency.Milliseconds())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"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) {
|
||||
|
||||
@@ -61,6 +61,19 @@ const (
|
||||
ErrCodeInvalidCredentials string = "invalid_credentials"
|
||||
ErrCodeImportFileExtension string = "import_file_extension"
|
||||
|
||||
// ErrCodePeerDisconnectFailed — ЧАСТИЧНЫЙ результат, а не отказ операции.
|
||||
//
|
||||
// Отзыв доступа состоит из двух половин: долговременной записи в базе,
|
||||
// которая закрывает новые подключения, и обращения к Traffic Stats `/kick`,
|
||||
// которое рвёт уже установленную сессию. Первая половина к моменту этого
|
||||
// кода уже применена и НЕ откатывается: достигнутое безопасное состояние
|
||||
// нельзя отменять из-за неудачи второго шага — откат вернул бы пиру полный
|
||||
// доступ.
|
||||
//
|
||||
// Поэтому код нужен отдельный. Без него оператор видел бы обычную ошибку и
|
||||
// сделал бы ровно неверный вывод: «не сработало, состояние прежнее».
|
||||
ErrCodePeerDisconnectFailed string = "peer_disconnect_failed"
|
||||
|
||||
// Коды состояния сессии. Панель различает «войдите» и «сессия кончилась»:
|
||||
// во втором случае оператор находится на рабочей странице, и молча
|
||||
// выбрасывать его на форму входа без объяснения нельзя.
|
||||
|
||||
@@ -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: <nil>` — сообщение без единого факта именно в том случае,
|
||||
// когда Hysteria что-то ответила и её ответ был бы полезен. Транспортная
|
||||
// ошибка при этом печаталась нормально, и внешне обе выглядели одинаково.
|
||||
//
|
||||
// Различать их стало обязательно: на этом пути теперь стоит отзыв доступа, и
|
||||
// «сессию разорвать не удалось» — единственный след, по которому оператор
|
||||
// поймёт, почему отключённый пир остался на связи.
|
||||
func describeFailure(err error, resp *http.Response) string {
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if resp != nil {
|
||||
return fmt.Sprintf("unexpected status %s", resp.Status)
|
||||
}
|
||||
return "no response"
|
||||
}
|
||||
|
||||
func NewHysteria2Api(apiPort int64) *Hysteria2Api {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+132
-26
@@ -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) {
|
||||
|
||||
+64
-2
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
)
|
||||
|
||||
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО HTTP, а не против подменённого
|
||||
// клиента.
|
||||
//
|
||||
// Контракт, который здесь доказывается, — официальный Traffic Stats API
|
||||
// Hysteria: метод, путь, заголовок Authorization и JSON-массив идентификаторов
|
||||
// в теле. Подменённый на уровне Go клиент доказал бы только то, что вызвана
|
||||
// нужная функция, и молча пережил бы, например, потерю заголовка. Слой proxy
|
||||
// ходит на 127.0.0.1:<порт из конфига>, а httptest слушает ровно там, поэтому
|
||||
// настоящий запрос ставится без единой правки продуктового кода.
|
||||
|
||||
const testTrafficStatsSecret = "test-traffic-stats-secret"
|
||||
|
||||
// trafficStatsStub — Traffic Stats API Hysteria в объёме, который использует
|
||||
// админка.
|
||||
type trafficStatsStub struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// online отдаётся на GET /online.
|
||||
online map[string]int64
|
||||
// onlineStatus != 0 подменяет ответ /online кодом отказа.
|
||||
onlineStatus int
|
||||
// kickStatus != 0 подменяет ответ /kick кодом отказа.
|
||||
kickStatus int
|
||||
|
||||
// Наблюдения.
|
||||
onlineCalls int
|
||||
kickCalls int
|
||||
kickedKeys [][]string
|
||||
kickAuth []string
|
||||
// disabledAtKick — состояние колонки disabled на момент прихода /kick.
|
||||
// Именно этим доказывается порядок «сначала запись, потом разрыв»:
|
||||
// проверить его после операции нельзя, там оба шага уже завершены.
|
||||
disabledAtKick []map[string]int64
|
||||
bannedAtKick []map[string]int64
|
||||
}
|
||||
|
||||
func (s *trafficStatsStub) snapshot(t *testing.T) (map[string]int64, map[string]int64) {
|
||||
t.Helper()
|
||||
|
||||
disabled := map[string]int64{}
|
||||
banned := map[string]int64{}
|
||||
peers, err := dao.ListPeer("1=1")
|
||||
if err != nil {
|
||||
return disabled, banned
|
||||
}
|
||||
for _, peer := range peers {
|
||||
if peer.Name == nil {
|
||||
continue
|
||||
}
|
||||
if peer.Disabled != nil {
|
||||
disabled[*peer.Name] = *peer.Disabled
|
||||
}
|
||||
if peer.BannedUntil != nil {
|
||||
banned[*peer.Name] = *peer.BannedUntil
|
||||
}
|
||||
}
|
||||
return disabled, banned
|
||||
}
|
||||
|
||||
// startTrafficStats поднимает Traffic Stats API на 127.0.0.1 и указывает на
|
||||
// него конфиг Hysteria, который читает админка.
|
||||
func startTrafficStats(t *testing.T, stub *trafficStatsStub) *trafficStatsStub {
|
||||
t.Helper()
|
||||
|
||||
if stub == nil {
|
||||
stub = &trafficStatsStub{}
|
||||
}
|
||||
if stub.online == nil {
|
||||
stub.online = map[string]int64{}
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
stub.mu.Lock()
|
||||
defer stub.mu.Unlock()
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/online":
|
||||
stub.onlineCalls++
|
||||
if stub.onlineStatus != 0 {
|
||||
w.WriteHeader(stub.onlineStatus)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(stub.online)
|
||||
case "/kick":
|
||||
stub.kickCalls++
|
||||
stub.kickAuth = append(stub.kickAuth, r.Header.Get("Authorization"))
|
||||
|
||||
disabled, banned := stub.snapshot(t)
|
||||
stub.disabledAtKick = append(stub.disabledAtKick, disabled)
|
||||
stub.bannedAtKick = append(stub.bannedAtKick, banned)
|
||||
|
||||
var keys []string
|
||||
if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
stub.kickedKeys = append(stub.kickedKeys, keys)
|
||||
|
||||
if stub.kickStatus != 0 {
|
||||
w.WriteHeader(stub.kickStatus)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
pointHysteriaConfigAt(t, server.URL)
|
||||
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
|
||||
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||||
}
|
||||
return stub
|
||||
}
|
||||
|
||||
// pointHysteriaConfigAt пишет конфиг Hysteria, в котором Traffic Stats API
|
||||
// слушает порт переданного адреса.
|
||||
func pointHysteriaConfigAt(t *testing.T, serverURL string) {
|
||||
t.Helper()
|
||||
|
||||
host := serverURL
|
||||
for _, prefix := range []string{"http://", "https://"} {
|
||||
if len(host) > len(prefix) && host[:len(prefix)] == prefix {
|
||||
host = host[len(prefix):]
|
||||
}
|
||||
}
|
||||
_, port, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", serverURL, err)
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
content := fmt.Sprintf("listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:%s\"\n secret: %q\n",
|
||||
port, testTrafficStatsSecret)
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err)
|
||||
}
|
||||
|
||||
previous := constant.Hysteria2ConfigPath
|
||||
constant.Hysteria2ConfigPath = path
|
||||
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||||
}
|
||||
|
||||
// pointHysteriaConfigAtDeadPort указывает конфиг на порт, который никто не
|
||||
// слушает: так воспроизводится недоступность Traffic Stats API.
|
||||
func pointHysteriaConfigAtDeadPort(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось занять порт: %v", err)
|
||||
}
|
||||
addr := listener.Addr().String()
|
||||
// Порт освобождается сразу: адрес известен, слушателя на нём нет.
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatalf("не удалось освободить порт: %v", err)
|
||||
}
|
||||
|
||||
pointHysteriaConfigAt(t, "http://"+addr)
|
||||
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
|
||||
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// withHysteriaRunning подменяет ответ systemd о состоянии службы.
|
||||
func withHysteriaRunning(t *testing.T, running bool) {
|
||||
t.Helper()
|
||||
|
||||
previous := hysteria2IsRunning
|
||||
hysteria2IsRunning = func() bool { return running }
|
||||
t.Cleanup(func() { hysteria2IsRunning = previous })
|
||||
}
|
||||
|
||||
func peerByName(t *testing.T, name string) (disabled int64, bannedUntil int64) {
|
||||
t.Helper()
|
||||
|
||||
peer, ok := snapshotPeers(t)[name]
|
||||
if !ok {
|
||||
t.Fatalf("пир %q не найден", name)
|
||||
}
|
||||
if peer.Disabled != nil {
|
||||
disabled = *peer.Disabled
|
||||
}
|
||||
if peer.BannedUntil != nil {
|
||||
bannedUntil = *peer.BannedUntil
|
||||
}
|
||||
return disabled, bannedUntil
|
||||
}
|
||||
|
||||
func requireDisconnectError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("частичный результат не сообщён: ошибки нет вовсе")
|
||||
}
|
||||
var peerErr *PeerError
|
||||
if !errors.As(err, &peerErr) {
|
||||
t.Fatalf("отказ не несёт машиночитаемого кода: %v", err)
|
||||
}
|
||||
if peerErr.Code != constant.ErrCodePeerDisconnectFailed {
|
||||
t.Fatalf("ожидался код %q, получен %q", constant.ErrCodePeerDisconnectFailed, peerErr.Code)
|
||||
}
|
||||
if peerErr.Field != "" {
|
||||
t.Errorf("частичный результат отнесён к полю формы %q, хотя относится к операции целиком", peerErr.Field)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DisconnectPeers ---------------------------------------------------------
|
||||
|
||||
func TestDisconnectPeersSendsOfficialKickContract(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startTrafficStats(t, nil)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if err := DisconnectPeers([]int64{id}); err != nil {
|
||||
t.Fatalf("разрыв сессии отказал: %v", err)
|
||||
}
|
||||
|
||||
if stub.kickCalls != 1 {
|
||||
t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls)
|
||||
}
|
||||
if got := stub.kickedKeys[0]; len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||
t.Fatalf("в /kick уехали не те идентификаторы: %v", got)
|
||||
}
|
||||
if stub.kickAuth[0] != testTrafficStatsSecret {
|
||||
t.Fatalf("секрет Traffic Stats API не передан в Authorization: %q", stub.kickAuth[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Разрыв сессии НИЧЕГО не пишет в базу. Прежний Hysteria2Kick заодно
|
||||
// проставлял banned_until, из-за чего им нельзя было воспользоваться для
|
||||
// операции «Отключить»: она записала бы временную блокировку — другой механизм
|
||||
// с другим сроком жизни.
|
||||
func TestDisconnectPeersDoesNotTouchPeerState(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, nil)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if err := DisconnectPeers([]int64{id}); err != nil {
|
||||
t.Fatalf("разрыв сессии отказал: %v", err)
|
||||
}
|
||||
|
||||
disabled, bannedUntil := peerByName(t, "alpha1")
|
||||
if disabled != 0 {
|
||||
t.Errorf("разрыв сессии изменил disabled: %d", disabled)
|
||||
}
|
||||
if bannedUntil != 0 {
|
||||
t.Errorf("разрыв сессии изменил banned_until: %d", bannedUntil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectPeersIsNoopWithoutTargets(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startTrafficStats(t, nil)
|
||||
|
||||
if err := DisconnectPeers(nil); err != nil {
|
||||
t.Fatalf("пустой список признан отказом: %v", err)
|
||||
}
|
||||
// Пир без authId Hysteria не знает: рвать нечего.
|
||||
id := seedPeer(t, "alpha1", "")
|
||||
if err := DisconnectPeers([]int64{id}); err != nil {
|
||||
t.Fatalf("пир без authId признан отказом: %v", err)
|
||||
}
|
||||
|
||||
if stub.kickCalls != 0 {
|
||||
t.Fatalf("сделан вызов /kick без единой цели: %d", stub.kickCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectPeersReportsApiFailure(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if err := DisconnectPeers([]int64{id}); err == nil {
|
||||
t.Fatal("отказ Traffic Stats API не сообщён")
|
||||
}
|
||||
}
|
||||
|
||||
// Состояние службы по systemd на этом пути не спрашивается вовсе: ответ
|
||||
// systemctl не отличает «служба неактивна» от «спросить не удалось», и на
|
||||
// прежнем пути его сбой отказывал операции при живой Hysteria.
|
||||
func TestDisconnectPeersIgnoresSystemdOpinion(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startTrafficStats(t, nil)
|
||||
withHysteriaRunning(t, false)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if err := DisconnectPeers([]int64{id}); err != nil {
|
||||
t.Fatalf("разрыв сессии отказал из-за мнения systemd: %v", err)
|
||||
}
|
||||
if stub.kickCalls != 1 {
|
||||
t.Fatalf("разрыв сессии пропущен из-за мнения systemd: вызовов /kick %d", stub.kickCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Отключение пира ---------------------------------------------------------
|
||||
|
||||
func TestUpdatePeerDisableWritesThenDisconnects(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startTrafficStats(t, nil)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
one := int64(1)
|
||||
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
|
||||
t.Fatalf("отключение пира отказало: %v", err)
|
||||
}
|
||||
|
||||
disabled, bannedUntil := peerByName(t, "alpha1")
|
||||
if disabled != 1 {
|
||||
t.Fatalf("пир не отключён: disabled=%d", disabled)
|
||||
}
|
||||
// Временная блокировка — независимый механизм, отключение её не ставит.
|
||||
if bannedUntil != 0 {
|
||||
t.Errorf("отключение проставило banned_until=%d", bannedUntil)
|
||||
}
|
||||
|
||||
if stub.kickCalls != 1 {
|
||||
t.Fatalf("активная сессия не разорвана: вызовов /kick %d", stub.kickCalls)
|
||||
}
|
||||
|
||||
// Порядок: к моменту прихода /kick запрет новых подключений уже в базе.
|
||||
// При обратном порядке клиент успевает переподключиться в окне между
|
||||
// разрывом и записью и остаётся на связи с формально отключённым пиром.
|
||||
if got := stub.disabledAtKick[0]["alpha1"]; got != 1 {
|
||||
t.Fatalf("/kick пришёл раньше записи disabled: на момент разрыва disabled=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Неудача разрыва НЕ откатывает запрет новых подключений: безопасная половина
|
||||
// операции достигнута, и возвращать пиру полный доступ из-за второго шага
|
||||
// нельзя.
|
||||
func TestUpdatePeerDisableKeepsStateWhenDisconnectFails(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
one := int64(1)
|
||||
err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one})
|
||||
requireDisconnectError(t, err)
|
||||
|
||||
if disabled, _ := peerByName(t, "alpha1"); disabled != 1 {
|
||||
t.Fatalf("запрет новых подключений откачен после неудачного разрыва: disabled=%d", disabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Включение пира не рвёт сессий и не трогает временную блокировку: это
|
||||
// независимые механизмы.
|
||||
func TestUpdatePeerEnableDoesNotDisconnect(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startTrafficStats(t, nil)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if err := KickPeer(id, 1_700_000_000_000); err != nil {
|
||||
t.Fatalf("временная блокировка отказала: %v", err)
|
||||
}
|
||||
kicksAfterBan := stub.kickCalls
|
||||
|
||||
zero := int64(0)
|
||||
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &zero}); err != nil {
|
||||
t.Fatalf("включение пира отказало: %v", err)
|
||||
}
|
||||
|
||||
if stub.kickCalls != kicksAfterBan {
|
||||
t.Fatalf("включение пира вызвало /kick: %d -> %d", kicksAfterBan, stub.kickCalls)
|
||||
}
|
||||
disabled, bannedUntil := peerByName(t, "alpha1")
|
||||
if disabled != 0 {
|
||||
t.Fatalf("пир не включён: disabled=%d", disabled)
|
||||
}
|
||||
if bannedUntil != 1_700_000_000_000 {
|
||||
t.Errorf("включение сбросило независимую временную блокировку: banned_until=%d", bannedUntil)
|
||||
}
|
||||
}
|
||||
|
||||
// Правка постороннего поля не является отзывом доступа и сессий не рвёт.
|
||||
func TestUpdatePeerUnrelatedFieldDoesNotDisconnect(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startTrafficStats(t, nil)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if err := UpdatePeer(id, dto.PeerUpdateDto{Remark: strPtr("ноутбук")}); err != nil {
|
||||
t.Fatalf("правка комментария отказала: %v", err)
|
||||
}
|
||||
if stub.kickCalls != 0 {
|
||||
t.Fatalf("правка комментария разорвала сессии: вызовов /kick %d", stub.kickCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Операция повторяема: условие смотрит на ЗАПРОШЕННОЕ состояние, а не на
|
||||
// переход из включённого. Иначе после неудачного разрыва оператору пришлось бы
|
||||
// включить пира, чтобы получить право отключить его снова.
|
||||
func TestUpdatePeerDisableIsRetryable(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
one := int64(1)
|
||||
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}))
|
||||
|
||||
stub.mu.Lock()
|
||||
stub.kickStatus = 0
|
||||
stub.mu.Unlock()
|
||||
|
||||
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
|
||||
t.Fatalf("повторное отключение отказало: %v", err)
|
||||
}
|
||||
if stub.kickCalls != 2 {
|
||||
t.Fatalf("повторная попытка не дошла до /kick: вызовов %d", stub.kickCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Временная блокировка ----------------------------------------------------
|
||||
|
||||
func TestKickPeerWritesBanOnceAndDisconnects(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startTrafficStats(t, nil)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
const bannedUntil = int64(1_700_000_000_000)
|
||||
if err := KickPeer(id, bannedUntil); err != nil {
|
||||
t.Fatalf("временная блокировка отказала: %v", err)
|
||||
}
|
||||
|
||||
disabled, banned := peerByName(t, "alpha1")
|
||||
if banned != bannedUntil {
|
||||
t.Fatalf("banned_until не записан: %d", banned)
|
||||
}
|
||||
// Временная блокировка и отключение независимы.
|
||||
if disabled != 0 {
|
||||
t.Errorf("временная блокировка изменила disabled: %d", disabled)
|
||||
}
|
||||
if stub.kickCalls != 1 {
|
||||
t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls)
|
||||
}
|
||||
if got := stub.bannedAtKick[0]["alpha1"]; got != bannedUntil {
|
||||
t.Fatalf("/kick пришёл раньше записи banned_until: на момент разрыва %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: прежний путь начинался с проверки состояния службы и возвращал
|
||||
// чистый отказ, НЕ СДЕЛАВ ничего, — при том что запись banned_until к этому
|
||||
// моменту уже применилась. Оператор видел «не сработало» у сработавшей
|
||||
// блокировки.
|
||||
func TestKickPeerReportsPartialResultWhenDisconnectFails(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
const bannedUntil = int64(1_700_000_000_000)
|
||||
requireDisconnectError(t, KickPeer(id, bannedUntil))
|
||||
|
||||
if _, banned := peerByName(t, "alpha1"); banned != bannedUntil {
|
||||
t.Fatalf("блокировка не применена, хотя сообщён частичный результат: banned_until=%d", banned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseKickPeerLeavesDisabledAlone(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, nil)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
one := int64(1)
|
||||
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
|
||||
t.Fatalf("отключение пира отказало: %v", err)
|
||||
}
|
||||
if err := KickPeer(id, 1_700_000_000_000); err != nil {
|
||||
t.Fatalf("временная блокировка отказала: %v", err)
|
||||
}
|
||||
if err := ReleaseKickPeer(id); err != nil {
|
||||
t.Fatalf("снятие блокировки отказало: %v", err)
|
||||
}
|
||||
|
||||
disabled, banned := peerByName(t, "alpha1")
|
||||
if banned != 0 {
|
||||
t.Fatalf("banned_until не сброшен: %d", banned)
|
||||
}
|
||||
if disabled != 1 {
|
||||
t.Fatalf("снятие временной блокировки включило отключённого пира: disabled=%d", disabled)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ограничение устройств (fail-closed) -------------------------------------
|
||||
|
||||
func TestHysteria2AuthRejectsWhenTrafficStatsRefuses(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError})
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||
t.Fatal("отказ Traffic Stats API впустил подключение без проверки лимита устройств")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHysteria2AuthRejectsWhenTrafficStatsUnreachable(t *testing.T) {
|
||||
newTestDB(t)
|
||||
pointHysteriaConfigAtDeadPort(t)
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||
t.Fatal("недоступность Traffic Stats API впустила подключение без проверки лимита устройств")
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия AUTH-02, вторая половина дыры и самая тихая: общий Hysteria2Online
|
||||
// отдавал ПУСТУЮ КАРТУ БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», —
|
||||
// а этот ответ не отличается от «спросить systemctl не удалось». Лимит
|
||||
// устройств переставал проверяться, и в журнале не появлялось ни строки.
|
||||
func TestHysteria2AuthEnforcesLimitWhenSystemdSaysStopped(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
|
||||
withHysteriaRunning(t, false)
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||
t.Fatal("мнение systemd отключило проверку лимита устройств")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHysteria2AuthAcceptsBelowDeviceLimit(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
gotID, gotAuth, err := Hysteria2Auth("alpha1-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("подключение в пределах лимита отклонено: %v", err)
|
||||
}
|
||||
if gotID != id || gotAuth != "alpha-auth-id" {
|
||||
t.Fatalf("авторизация вернула не того пира: id=%d authId=%q", gotID, gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHysteria2AuthRejectsAtDeviceLimit(t *testing.T) {
|
||||
newTestDB(t)
|
||||
// seedPeer создаёт пира с maxDevices = 3.
|
||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||
t.Fatal("подключение сверх лимита устройств принято")
|
||||
}
|
||||
}
|
||||
|
||||
// Повреждённая граница — это не «безлимит». Строка без пригодного maxDevices
|
||||
// на пути принятия решения о доступе обязана вести к отказу.
|
||||
func TestHysteria2AuthRejectsUnusableDeviceLimit(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, nil)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"max_devices": 0}); err != nil {
|
||||
t.Fatalf("не удалось подготовить повреждённую строку: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||
t.Fatal("пир без пригодного лимита устройств принят")
|
||||
}
|
||||
}
|
||||
|
||||
// Путь машинной авторизации проходят ВСЕ подключения пиров, поэтому
|
||||
// разыменование nil на нём означает панику в обработчике, а не отказ одному
|
||||
// клиенту.
|
||||
func TestHysteria2AuthRejectsPeerWithoutIdentity(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, nil)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"auth_id": ""}); err != nil {
|
||||
t.Fatalf("не удалось подготовить повреждённую строку: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("повреждённая строка уронила обработчик авторизации: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||
t.Fatal("пир без идентичности принят")
|
||||
}
|
||||
}
|
||||
|
||||
// Отображение остаётся терпимым: пустая картина подключений — честный ответ на
|
||||
// вопрос «кто сейчас на связи», когда служба остановлена. Строгость нужна
|
||||
// только там, где принимается решение о доступе.
|
||||
func TestHysteria2OnlineStaysTolerantForDisplay(t *testing.T) {
|
||||
newTestDB(t)
|
||||
pointHysteriaConfigAtDeadPort(t)
|
||||
withHysteriaRunning(t, false)
|
||||
|
||||
online, err := Hysteria2Online()
|
||||
if err != nil {
|
||||
t.Fatalf("отображение подключений стало отказывать: %v", err)
|
||||
}
|
||||
if len(online) != 0 {
|
||||
t.Fatalf("ожидалась пустая картина подключений, получено %v", online)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ func TestCreatePeerRefusesReservedName(t *testing.T) {
|
||||
// оператор не смог бы ни отключить bootstrap-пира, ни ограничить ему квоту.
|
||||
func TestUpdatePeerAllowsNonIdentityChangesOnBootstrapPeer(t *testing.T) {
|
||||
newTestDB(t)
|
||||
// Отключение пира — это ещё и разрыв его активных сессий, поэтому Traffic
|
||||
// Stats API обязан отвечать. Без него операция завершилась бы частичным
|
||||
// результатом (запись применена, сессия не разорвана), и тест доказывал бы
|
||||
// не то, ради чего написан.
|
||||
startTrafficStats(t, nil)
|
||||
id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
|
||||
|
||||
disabled := int64(1)
|
||||
|
||||
@@ -54,6 +54,27 @@ var ErrPeerNameReserved = &PeerError{
|
||||
),
|
||||
}
|
||||
|
||||
// PeerDisconnectError — долговременное состояние применено, активную сессию
|
||||
// завершить не удалось.
|
||||
//
|
||||
// Причина отказа `/kick` в сообщение НЕ попадает. Она приходит из слоя proxy в
|
||||
// виде транспортного текста («http connection error») и оператору не говорит
|
||||
// ничего, а диагностическая ценность у неё есть только в журнале, куда она и
|
||||
// пишется вызывающим. Оператору нужно другое: что именно уже сделано и что
|
||||
// осталось сделать.
|
||||
//
|
||||
// Отказ относится к операции целиком, а не к полю формы: поля, которое можно
|
||||
// было бы исправить, здесь нет.
|
||||
func PeerDisconnectError() *PeerError {
|
||||
return &PeerError{
|
||||
Code: constant.ErrCodePeerDisconnectFailed,
|
||||
Message: "новые подключения пира запрещены, но завершить его активные " +
|
||||
"сессии не удалось: Traffic Stats API Hysteria недоступен. " +
|
||||
"Уже установленное соединение может продолжать работать до " +
|
||||
"переподключения клиента",
|
||||
}
|
||||
}
|
||||
|
||||
// PeerNameTakenError — имя уже занято другим пиром.
|
||||
func PeerNameTakenError(name string) *PeerError {
|
||||
return &PeerError{
|
||||
|
||||
@@ -269,6 +269,15 @@ if fl.(*validate).fldIsPointer && getValue(field) != nil {
|
||||
|
||||
---
|
||||
|
||||
## Разбор кода после этих исправлений
|
||||
|
||||
Проверка внесённых здесь исправлений по дереву на коммите `c0a43ae9` нашла
|
||||
дефекты, которых хостовой прогон `rc1` не показывал, — включая два отдельных
|
||||
пути, по которым молча снималось ограничение устройств, и отключение пира, не
|
||||
разрывавшее его активную сессию. Они перечислены в
|
||||
[2026-09-01-v1.0.0-rc2-preflight-findings.md](2026-09-01-v1.0.0-rc2-preflight-findings.md)
|
||||
и закрыты до сборки `rc2`.
|
||||
|
||||
## Что осталось сделать до финального v1.0.0
|
||||
|
||||
1. пересобрать `rc2` и повторить build/security acceptance;
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
# Разбор кода перед сборкой `1.0.0-rc2`
|
||||
|
||||
Источник — не прогон на хосте, а разбор дерева на коммите `c0a43ae9` и сверка
|
||||
Hysteria-интеграции с официальной документацией Hysteria 2 и с API Element Plus.
|
||||
Поэтому файл отдельный: дефекты приёмки `rc1` перечислены в
|
||||
[2026-09-01-v1.0.0-rc1-ux-findings.md](2026-09-01-v1.0.0-rc1-ux-findings.md), и
|
||||
смешивать их с найденным при чтении кода значило бы приписать хостовому прогону
|
||||
то, чего он не показывал.
|
||||
|
||||
Проверить их на живом сервере ещё предстоит: на тестовом хосте по-прежнему
|
||||
стоит `rc1`. Список ручных проверок — в конце документа.
|
||||
|
||||
## Сводка
|
||||
|
||||
| ID | Дефект | Приоритет | Статус |
|
||||
| --- | --- | --- | --- |
|
||||
| UX-06 | Нет быстрого включения/отключения пира; отключение не рвёт сессию | P1 | закрыт |
|
||||
| UX-07 | Подсказка «Экспорт настроек» без ограничения ширины | P2 | закрыт |
|
||||
| UX-08 | Срок временной блокировки зашит в код и не сообщается | P2 | закрыт |
|
||||
| UX-09 | Отмена подтверждения считалась ошибкой | P2 | закрыт |
|
||||
| UX-10 | Подсказка имени пира описывала не действующее правило | P3 | закрыт |
|
||||
| UX-11 | Отказ показывался дважды, а транспортный — ни разу | P1 | закрыт |
|
||||
| LOG-01 | Журнал запросов пишет пустой `msg` | P1 | закрыт |
|
||||
| LOG-02 | Ширины колонок журнала не заданы | P1 UX | закрыт |
|
||||
| LOG-03 | Отказ выгрузки журнала не ловился и глушился | P1 | закрыт |
|
||||
| LOG-04 | Страница системных логов ломалась, пока нет файла журнала | P1 | закрыт |
|
||||
| LOG-05 | Одна битая строка журнала ломала весь ответ | P1 | закрыт |
|
||||
| AUTH-01 | `maxDevices` fail-open при отказе `/online` | P1 | закрыт |
|
||||
| AUTH-02 | `maxDevices` не проверялся, когда systemd отвечал «неактивна» | P1 | закрыт |
|
||||
| CORE-01 | Ошибка называет `TCP port` для UDP-эндпоинта | P3 | закрыт |
|
||||
| CORE-02 | `banned_until` писался дважды, отказ отчитывался как полный | P1 | закрыт |
|
||||
| TYPE-01 | Типы полей журнала в панели расходились с сервером | P3 | закрыт |
|
||||
|
||||
LOG-04, LOG-05, AUTH-02, CORE-02, UX-08…UX-11 и TYPE-01 в исходный разбор не
|
||||
входили и найдены при проверке его выводов по коду. UX-11 нашёлся позже
|
||||
остальных — при проверке уже внесённых исправлений.
|
||||
|
||||
---
|
||||
|
||||
## UX-06 — отключение пира не отзывало доступ
|
||||
|
||||
**Наблюдалось:** пункта включения/отключения нет в меню строки вовсе. Сменить
|
||||
состояние можно было только через форму изменения.
|
||||
|
||||
**Корневая причина глубже отсутствующего пункта.** Вернуть пункт и вызвать
|
||||
`PATCH /peers/:id` с `disabled: 1` было бы недостаточно: `UpdatePeer` записывал
|
||||
поле в SQLite и на этом заканчивался.
|
||||
|
||||
Запись `disabled=1` видит выборка в `Hysteria2Auth`, то есть она закрывает
|
||||
только БУДУЩИЕ обращения к HTTP-auth. Установленная QUIC-сессия живёт своей
|
||||
жизнью и сама по себе не разрывается: после «Отключить» пир продолжал
|
||||
пользоваться доступом сколько угодно долго, пока не переподключался по своей
|
||||
воле. Панель при этом показывала его отключённым.
|
||||
|
||||
Официальная документация Hysteria описывает эти половины как пару: Traffic
|
||||
Stats `/kick` завершает сессию, но клиент немедленно переподключается, поэтому
|
||||
одновременно требуется блокировка в auth backend. По отдельности не работает ни
|
||||
одна.
|
||||
|
||||
У продукта были обе половины, но разведённые по разным операциям:
|
||||
|
||||
```text
|
||||
disabled=1 -> блокирует последующий HTTP-auth
|
||||
POST /kick -> разрывает текущую сессию
|
||||
```
|
||||
|
||||
**Как закрыто.**
|
||||
|
||||
1. `service.DisconnectPeers(ids)` — только официальный `/kick`, без единой
|
||||
записи в базу. Прежний `Hysteria2Kick` вместе с разрывом проставлял
|
||||
`banned_until`, поэтому воспользоваться им для отключения было нельзя:
|
||||
операция записала бы заодно временную блокировку — другой механизм с другим
|
||||
сроком жизни и другим способом снятия.
|
||||
2. `UpdatePeer` при `disabled=1` выполняет обе половины: сначала долговременную
|
||||
запись, затем разрыв.
|
||||
3. Порядок обратному не подлежит. При обратном клиент успевает
|
||||
переподключиться в окне между `/kick` и записью и остаётся на связи с
|
||||
формально отключённым пиром. Порядок доказывается тестом, который снимает
|
||||
состояние базы В МОМЕНТ прихода `/kick`: после операции оба шага уже
|
||||
выполнены и проверять там нечего.
|
||||
4. Неудача разрыва НЕ откатывает `disabled`. Безопасная половина достигнута, и
|
||||
возвращать пиру полный доступ из-за отказа второго шага нельзя.
|
||||
5. Состояние службы по systemd на этом пути не спрашивается. `util.Exec`
|
||||
схлопывает «systemctl вернул 3» и «запустить systemctl не удалось» в одну
|
||||
ошибку, поэтому прежняя проверка `!Hysteria2IsRunning() -> отказ` отказывала
|
||||
бы операции при живой Hysteria. Обращение к `/kick` отвечает на нужный
|
||||
вопрос напрямую.
|
||||
6. Условие смотрит на ЗАПРОШЕННОЕ состояние, а не на переход из включённого:
|
||||
иначе после неудачного разрыва оператору пришлось бы включить пира, чтобы
|
||||
получить право отключить его снова.
|
||||
|
||||
**Частичный результат сообщается кодом, а не прозой.** Отдельный
|
||||
`peer_disconnect_failed`: без него оператор прочитал бы обычную ошибку как «не
|
||||
сработало, состояние прежнее» — вывод, прямо противоположный истине. Панель
|
||||
показывает его предупреждением и обновляет строку.
|
||||
|
||||
**Меню переведено на `command`.** `@click` на каждом `el-dropdown-item`
|
||||
заменён штатным контрактом `el-dropdown`: команда приходит в одно место, и
|
||||
добавить пункт, забыв его подключить, становится невозможно.
|
||||
|
||||
---
|
||||
|
||||
## AUTH-01 и AUTH-02 — ограничение устройств отключалось само
|
||||
|
||||
`Hysteria2Auth` после проверки пира спрашивал `/online`. При отказе:
|
||||
|
||||
```go
|
||||
onlineUsers, err := Hysteria2Online()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Warn(...)
|
||||
return *peer.Id, *peer.AuthId, nil
|
||||
}
|
||||
```
|
||||
|
||||
Недоступность внутреннего `127.0.0.1` превращала объявленный в панели «Лимит
|
||||
устройств: 3» в безлимит. Узнать об этом оператор мог только по строке `WARN` в
|
||||
журнале, которую никто не читает.
|
||||
|
||||
**Вторая половина дыры оказалась тише первой и в исходный разбор не входила.**
|
||||
`Hysteria2Online` начинался с ярлыка:
|
||||
|
||||
```go
|
||||
if !Hysteria2IsRunning() {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Пустая карта БЕЗ ОШИБКИ — это «онлайн никого», то есть лимит не проверяется, и
|
||||
в журнале не появляется ни строки. А `Hysteria2IsRunning` отвечает через
|
||||
`util.Exec`, который не отличает «служба неактивна» от «спросить не удалось».
|
||||
|
||||
**Как закрыто.** Пути разделены по назначению:
|
||||
|
||||
* `Hysteria2Online` — для ОТОБРАЖЕНИЯ (дашборд, признак online в списке).
|
||||
Терпимость сохранена: пустая картина — честный ответ на вопрос «кто сейчас на
|
||||
связи», когда служба остановлена.
|
||||
* `hysteria2Online` — фактический ответ Traffic Stats API, без ярлыков.
|
||||
Недоступность остаётся ошибкой. Этим путём идёт авторизация.
|
||||
|
||||
Направление отказа выбрано fail-closed осознанно: запрос авторизации приходит
|
||||
ОТ Hysteria, то есть в момент проверки Hysteria заведомо жива, а её Traffic
|
||||
Stats API слушает loopback внутри того же процесса. Его недоступность здесь —
|
||||
аномалия, а не штатное состояние.
|
||||
|
||||
**Следствие, о котором нужно знать оператору.** `maxDevices` имеет `min=1`,
|
||||
безлимита у него не бывает, поэтому недоступность Traffic Stats API отказывает
|
||||
в подключении всем пирам сразу — и это записывается в журнал уровнем `error`, а
|
||||
не `warn`. Обратный выбор означал бы молчаливое снятие лимита со всех пиров
|
||||
одновременно.
|
||||
|
||||
Заодно закрыты два соседних места на том же пути: секрет Traffic Stats API
|
||||
читался как `*config.Value` без проверки на nil (паника в обработчике
|
||||
machine-auth, то есть на пути каждого подключения пира), а повреждённый
|
||||
`maxDevices` трактовался бы как отсутствие границы.
|
||||
|
||||
---
|
||||
|
||||
## CORE-02 — временная блокировка отчитывалась отказом, будучи применённой
|
||||
|
||||
`KickPeer` записывал `banned_until`, затем звал `Hysteria2Kick`, который
|
||||
записывал `banned_until` ВТОРОЙ РАЗ тем же значением. Хуже дублирования был
|
||||
порядок отказа: `Hysteria2Kick` начинался с проверки состояния службы и
|
||||
возвращал ошибку, не сделав ничего, — но первая запись к этому моменту уже
|
||||
применилась. Операция отвечала чистым отказом, находясь в применённом
|
||||
состоянии.
|
||||
|
||||
Закрыто тем же примитивом, что и UX-06: долговременная запись, затем
|
||||
`DisconnectPeers`, затем — при неудаче разрыва — частичный результат отдельным
|
||||
кодом.
|
||||
|
||||
Механизмы остались независимыми: `banned_until` истекает сам, `disabled`
|
||||
снимается только руками; включение пира не сбрасывает временную блокировку, а
|
||||
её снятие не включает отключённого пира.
|
||||
|
||||
---
|
||||
|
||||
## LOG-01 — причина пустых системных логов
|
||||
|
||||
Это **не** рассогласование модели отображения с форматом файла. `LogSystemVo`
|
||||
полностью соответствует структуре записи.
|
||||
|
||||
Middleware собирал поля правильно, но затем вызывал:
|
||||
|
||||
```go
|
||||
entry.Error()
|
||||
entry.Warn()
|
||||
entry.Info()
|
||||
```
|
||||
|
||||
без аргумента сообщения, и logrus честно записывал `"msg":""` для каждого HTTP
|
||||
запроса. Пустой столбец на экране был точным отражением того, что записал
|
||||
backend.
|
||||
|
||||
**Как закрыто.** `middleware.RequestLogMessage` собирает строку ИЗ ТЕХ ЖЕ
|
||||
величин, что уже лежат в структурных полях:
|
||||
|
||||
```text
|
||||
GET /api/peers → 200 (7 ms)
|
||||
POST /internal/hysteria/auth → 200 (2 ms)
|
||||
```
|
||||
|
||||
Запись остаётся машиночитаемой; `msg` существует, чтобы человек мог прочитать
|
||||
её, не собирая строку из шести колонок.
|
||||
|
||||
Query-строка сюда не попадает. Это действующий контракт безопасности, а не
|
||||
небрежность: Hysteria обращается к машинному endpoint'у как
|
||||
`/internal/hysteria/auth?access_token=<machine token>` при каждом подключении
|
||||
пира, а журнал отдаётся оператору через `ExportLog` и уезжает в
|
||||
diagnostics-бандл. Тест проверяет обе половины сразу: `msg` непустой И не несёт
|
||||
ни токена, ни знака `?`.
|
||||
|
||||
---
|
||||
|
||||
## LOG-04 и LOG-05 — страница логов ломалась двумя разными способами
|
||||
|
||||
Ни один из них в исходный разбор не входил.
|
||||
|
||||
**LOG-04.** Ветка «файла журнала ещё нет» отвечала голым массивом:
|
||||
|
||||
```go
|
||||
vo.Success(logSystemVos, c)
|
||||
```
|
||||
|
||||
Панель читает `data.records`, поэтому получала `undefined` и передавала его в
|
||||
`:data` таблицы. То есть на свежепоставленном хосте — до первой записи в
|
||||
журнал — страница системных логов не работала вовсе. Это ровно тот сценарий,
|
||||
который проверяется на приёмке каждой чистой установки.
|
||||
|
||||
**LOG-05.** При неразбираемой строке выполнялось:
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
vo.Fail("Unable to unmarshal log data", c)
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
Ответ записывался в поток, цикл шёл дальше, а в конце безусловно выполнялся
|
||||
`vo.Success`. Клиент получал два JSON-документа подряд, то есть невалидный
|
||||
ответ. Достаточно было ОДНОЙ битой строки, чтобы страница перестала
|
||||
открываться целиком — а строка бьётся штатно: lumberjack ротирует файл, и
|
||||
обрыв последней записи на границе ротации — обычное событие.
|
||||
|
||||
**Как закрыто.** Форма ответа `{records, total}` на всех ветках; неразбираемая
|
||||
строка пропускается без записи ответа — остальные записи прочитаны и полезны.
|
||||
|
||||
---
|
||||
|
||||
## LOG-02 и LOG-03 — обе страницы журнала чинились дважды
|
||||
|
||||
Страницы системного журнала и журнала Hysteria были побайтово одинаковы, кроме
|
||||
вызываемого API, и несли одни и те же дефекты в двух экземплярах.
|
||||
|
||||
**LOG-02.** Ни `width`, ни `min-width` не заданы, поэтому Element Plus делил
|
||||
ширину практически поровну: уровень и время получали по трети строки, а
|
||||
сообщение — единственное содержимое журнала — тоже треть. Официальный API
|
||||
разделяет `width` (фиксирует) и `min-width` (участвует в распределении
|
||||
остатка).
|
||||
|
||||
**LOG-03.** Сетевой запрос стоял ПЕРЕД `try`:
|
||||
|
||||
```ts
|
||||
let response = await exportLogApi(...);
|
||||
try { ... } catch (e) { /* empty */ }
|
||||
```
|
||||
|
||||
Отказ самого запроса этим `catch` не ловился вовсе, а всё внутри глушилось
|
||||
молча. Оператор нажимал «Экспорт» и не получал ни файла, ни причины.
|
||||
|
||||
**Как закрыто.** Общий `components/LogViewer` — ширины, перенос сообщения,
|
||||
выгрузка и обработка её отказа объявлены один раз. Служебные колонки
|
||||
зафиксированы, колонка сообщения растягивается за счёт остатка и ПЕРЕНОСИТСЯ, а
|
||||
не обрезается многоточием: у Hysteria в `msg` приезжает диагностический JSON, и
|
||||
обрезанная строка не отвечает ни на один вопрос, ради которого страницу
|
||||
открыли.
|
||||
|
||||
**Сверх разбора: копий скачивания было четыре, а не три.** Четвёртую —
|
||||
выгрузку серверного конфига Hysteria — нашёл контрактный тест, потребовавший
|
||||
единственности `createObjectURL`. Она несла тот же дефект порядка и вдобавок
|
||||
падала на `dis.split(...)` при отсутствующем `Content-Disposition`, и это
|
||||
исключение тоже глушилось. Сборка ссылки на скачивание живёт теперь в
|
||||
`utils/download.ts` одна.
|
||||
|
||||
---
|
||||
|
||||
## UX-07 — ширина всплывающей подсказки
|
||||
|
||||
Подсказка объявлялась без ограничения, поэтому длинный перевод получал
|
||||
естественную ширину popper и растягивался почти на весь экран одной строкой.
|
||||
Element Plus предоставляет для этого штатный `popper-style`; ограничение
|
||||
поставлено им, а не глобальным CSS.
|
||||
|
||||
---
|
||||
|
||||
## UX-08, UX-09, UX-10 — найдено при разборе панели пиров
|
||||
|
||||
**UX-08.** `handleKick` зашивал `Date.now() + 60 * 60 * 1000` прямо в
|
||||
обработчик: пункт «Отключить» молча блокировал пира на час без подтверждения, а
|
||||
сколько продлится блокировка, не сообщалось ни до, ни после. Ключи локализации
|
||||
`peer.kickUtilTime` и `peer.releaseSuccess` при этом существовали и были
|
||||
мёртвыми. Теперь срок называется в подтверждении, а результат — сообщением;
|
||||
пункты переименованы так, чтобы «временно заблокировать» не путалось с
|
||||
«отключить пир».
|
||||
|
||||
**UX-09.** `ElMessageBox` отклоняет промис при нажатии «Отмена».
|
||||
`await ElMessageBox.confirm(...)` без разбора отказа оставлял необработанное
|
||||
отклонение промиса на каждую отмену — в четырёх местах страницы пиров и ещё
|
||||
одном в верхней панели (выход из системы). Отмена — это ОТВЕТ оператора;
|
||||
переведена в обычное `false` через `confirmAction`.
|
||||
|
||||
**UX-10.** Подсказка имени пира обещала «латиница, цифры и дефис», тогда как
|
||||
действующее правило (`service.IsValidPeerName`) принимает
|
||||
`a-z A-Z 0-9 !@#$%^&*()_+-=`. Подсказка осталась от правила, действовавшего до
|
||||
EX-03: она обещала более узкий набор, чем сервер принимает, и оператор не имел
|
||||
причин пробовать разрешённые символы. Тест теперь читает набор из серверной
|
||||
константы `PeerNameCharset`, поэтому разойтись снова они не могут.
|
||||
|
||||
---
|
||||
|
||||
## UX-11 — сообщение об отказе показывалось дважды или ни разу
|
||||
|
||||
Найдено при проверке собственных исправлений: два соседних дефекта в одном
|
||||
механизме, и оба вскрылись только когда действия строки пира начали сообщать
|
||||
свой исход сами.
|
||||
|
||||
**Дважды.** `deletePeerApi`, `resetPeerTrafficApi`, `kickPeerApi` и
|
||||
`releaseKickPeerApi` не объявляли `skipErrorToast`, поэтому после UX-06 отказ
|
||||
показывался и общим перехватчиком, и страницей. Для частичного результата
|
||||
отзыва доступа это давало два противоречащих сообщения об одном событии:
|
||||
предупреждение «состояние применено наполовину» и рядом ошибку.
|
||||
|
||||
**Ни разу.** Флаг `skipErrorToast` гасил не только отказ API, но и
|
||||
ТРАНСПОРТНЫЙ отказ — обрыв соединения, таймаут, HTTP-статус вне 2xx. При этом
|
||||
все страницы, объявлявшие флаг, в своих обработчиках писали
|
||||
`// транспортный отказ уже показан общим перехватчиком` и молчали. Утверждение
|
||||
было ложным: обрыв соединения при сохранении пира, его удалении или отзыве
|
||||
доступа не показывал оператору ничего — операция просто не происходила молча.
|
||||
Дефект существовал и до этого прохода, у `savePeerApi` и `updatePeerApi`.
|
||||
|
||||
**Как закрыто.** Флаг отнесён только к отказу API — тому, у которого есть
|
||||
конверт с `code` и `errors` и, значит, есть что разбирать. Транспортный отказ
|
||||
показывается всегда: у него конверта нет, страница о нём сказать ничего не
|
||||
может, и молчание о нём означает операцию без объяснений. Обе половины
|
||||
закреплены тестами.
|
||||
|
||||
---
|
||||
|
||||
## CORE-01 и TYPE-01 — мелкие расхождения
|
||||
|
||||
**CORE-01.** `resolvePublicEndpoint` при невалидном `HY2XS_PUBLIC_PORT` писал
|
||||
`must be a valid TCP port`, хотя публичный endpoint Hysteria — UDP/QUIC.
|
||||
Транспорт из формулировки убран, чтобы не закладывать в сообщение об ошибке
|
||||
заведомо ложную семантику.
|
||||
|
||||
**TYPE-01.** В панели `latencyTime` и `statusCode` объявлены строками, а сервер
|
||||
шлёт их как `int64`. Пока обе колонки не отображались, расхождение было
|
||||
безвредным; после LOG-01 и LOG-02 оно стало бы обычной ошибкой сравнения или
|
||||
форматирования.
|
||||
|
||||
---
|
||||
|
||||
## Чем закреплено
|
||||
|
||||
**Тесты Go** (`apps/service/peer_access_test.go`,
|
||||
`apps/controller/log_test.go`, `apps/middleware/log_test.go`):
|
||||
|
||||
* контракт `/kick` проверяется против НАСТОЯЩЕГО HTTP — метод, путь, заголовок
|
||||
`Authorization`, JSON-массив идентификаторов. Подменённый на уровне Go клиент
|
||||
доказал бы только то, что вызвана нужная функция, и молча пережил бы потерю
|
||||
заголовка;
|
||||
* порядок «запись → разрыв» — снимком состояния базы в момент прихода `/kick`;
|
||||
* неоткат `disabled` и `banned_until` при неудаче разрыва; повторяемость
|
||||
операции;
|
||||
* независимость `disabled` и `banned_until` друг от друга;
|
||||
* fail-closed при отказе `/online`, при недоступном порте и при systemd,
|
||||
отвечающем «служба неактивна»; граница `device == maxDevices`; повреждённый
|
||||
`maxDevices`;
|
||||
* сохранение терпимости пути отображения;
|
||||
* непустой `msg` вместе с отсутствием в нём токена и query-строки;
|
||||
* форма ответа страницы логов на всех ветках и пропуск битой строки.
|
||||
|
||||
**Контрактные тесты панели** (`tools/test/frontend-contract.test.ts`): общий
|
||||
`LogViewer` на обеих страницах, явные ширины колонок, запрос внутри `try`,
|
||||
единственность сборки скачивания, меню на `command` с пунктом
|
||||
`toggle-disabled`, ограничение ширины подсказки, единственный
|
||||
`ElMessageBox.confirm`, разбор частичного результата по коду, совпадение
|
||||
подсказки имени с серверной константой.
|
||||
|
||||
**Гейты приёмки** (`tools/build/lib/acceptance.sh`):
|
||||
`run_access_revocation_acceptance` и `run_observability_acceptance`.
|
||||
|
||||
Отдельно: проверка «единственный `ElMessageBox.confirm`» сначала поймала
|
||||
собственный комментарий, объясняющий, почему прямого вызова здесь больше нет, —
|
||||
ровно та ловушка, о которой предупреждает `code_without_comments` в
|
||||
`acceptance.sh`. Проверки панели теперь тоже отбрасывают комментарии.
|
||||
|
||||
---
|
||||
|
||||
## Что проверяется руками на `rc2`
|
||||
|
||||
Машина этого не докажет:
|
||||
|
||||
1. отключить пир с активным подключением и убедиться, что соединение
|
||||
действительно обрывается, а не только меняется плашка в списке;
|
||||
2. повторно подключиться отключённым пиром и получить отказ;
|
||||
3. включить пир обратно и убедиться, что подключение восстанавливается;
|
||||
4. остановить `hysteria-server`, отключить пир и прочитать предупреждение о
|
||||
частичном результате; убедиться, что строка показывает применённое
|
||||
состояние;
|
||||
5. проверить fail-closed `maxDevices`: сломать Traffic Stats API и убедиться,
|
||||
что подключение отклоняется, а в журнале появляется запись уровня `error`;
|
||||
6. открыть страницу системных логов на свежей установке ДО появления файла
|
||||
журнала;
|
||||
7. прочитать столбец `msg` на обеих страницах логов, проверить ширины колонок и
|
||||
перенос длинного JSON Hysteria;
|
||||
8. выгрузить оба журнала и конфиг Hysteria; отдельно проверить поведение при
|
||||
остановленной админке — отказ обязан быть виден;
|
||||
9. проверить ширину подсказки «Экспорт настроек» на узком экране;
|
||||
10. проверить, что отмена любого подтверждения не оставляет ошибок в консоли
|
||||
браузера.
|
||||
@@ -31,3 +31,13 @@
|
||||
| Прогон | Дефекты |
|
||||
| --- | --- |
|
||||
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
|
||||
|
||||
## Разборы кода между прогонами
|
||||
|
||||
Отдельно от отчётов о прогонах: дефекты, найденные чтением дерева и сверкой с
|
||||
официальной документацией, а не наблюдением на хосте. Провенанс у них другой, и
|
||||
приписывать их хостовому прогону нельзя — он их не показывал.
|
||||
|
||||
| Дата | Основание | Находки |
|
||||
| --- | --- | --- |
|
||||
| 2026-09-01 | коммит `c0a43ae9`, сверка с Hysteria 2 и Element Plus | [UX-06…UX-10, LOG-01…LOG-05, AUTH-01/02, CORE-01/02, TYPE-01](2026-09-01-v1.0.0-rc2-preflight-findings.md) |
|
||||
|
||||
@@ -113,9 +113,21 @@ Hysteria обращается к машинному endpoint'у как
|
||||
пира. Поэтому в журнале админки пишется **путь**, а не `RequestURI`:
|
||||
|
||||
```json
|
||||
{ "reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" }
|
||||
{ "msg": "POST /internal/hysteria/auth → 200 (2 ms)",
|
||||
"reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" }
|
||||
```
|
||||
|
||||
Поле `msg` собирается из тех же величин, что уже лежат в структурных полях, и
|
||||
не добавляет к ним ничего: запись остаётся машиночитаемой, а сообщение
|
||||
существует, чтобы человек мог прочитать строку журнала, не собирая её из шести
|
||||
колонок. Раньше `entry.Info()` вызывался без аргумента, и logrus записывал
|
||||
`"msg":""` для каждого запроса — страница системных логов показывала оператору
|
||||
пустой столбец, точно отражая содержимое файла.
|
||||
|
||||
Читаемость сообщения не является лазейкой для query-строки: в `msg` попадает
|
||||
только путь, и это закреплено тестом, который проверяет обе половины сразу —
|
||||
сообщение непустое И не несёт ни токена, ни знака `?`.
|
||||
|
||||
Пока логировался `RequestURI`, действующий machine token оседал открытым
|
||||
текстом в `/var/log/hy2xs/hy2xs-admin.log`. Этот файл отдаётся оператору через
|
||||
`ExportLog` и попадает в diagnostics-бандл, то есть секрет утекал наружу в
|
||||
@@ -525,6 +537,78 @@ upstream выберет для нового секрета. Список мар
|
||||
Конфигурация Hysteria остаётся доступной панели **на чтение и на выгрузку**:
|
||||
`GET /config/getHysteria2Config` и `POST /config/exportHysteria2Config`.
|
||||
|
||||
### Отзыв доступа к VPN состоит из двух половин
|
||||
|
||||
Панель не управляет жизненным циклом Hysteria, но доступом пиров управляет
|
||||
целиком — и здесь у неё есть ровно один механизм, требующий обеих половин
|
||||
официального контракта Hysteria.
|
||||
|
||||
```text
|
||||
disabled = 1 закрывает БУДУЩИЕ обращения к HTTP-auth
|
||||
POST /kick завершает УЖЕ УСТАНОВЛЕННУЮ сессию
|
||||
```
|
||||
|
||||
Ни одна половина не работает по отдельности. Запись `disabled=1` видит только
|
||||
выборка в `Hysteria2Auth`, то есть проверяется при следующем подключении;
|
||||
установленная QUIC-сессия живёт своей жизнью и сама не разрывается. Обратно:
|
||||
`/kick` завершает сессию, но клиент немедленно переподключается — поэтому
|
||||
официальная документация Hysteria и требует одновременной блокировки в auth
|
||||
backend.
|
||||
|
||||
**Порядок обязателен и обратному не подлежит:**
|
||||
|
||||
```text
|
||||
1. записать disabled = 1 (долговременное состояние)
|
||||
2. POST /kick по authId пира (разрыв)
|
||||
```
|
||||
|
||||
При обратном порядке клиент успевает переподключиться в окне между разрывом и
|
||||
записью и остаётся на связи с формально отключённым пиром.
|
||||
|
||||
**Неудача второго шага не откатывает первый.** Безопасная половина достигнута;
|
||||
возвращать пиру полный доступ из-за отказа разрыва нельзя. Операция отвечает
|
||||
частичным результатом с кодом `peer_disconnect_failed`, панель показывает его
|
||||
предупреждением и обновляет строку. Повторить операцию можно тем же действием:
|
||||
условие смотрит на запрошенное состояние, а не на переход из включённого.
|
||||
|
||||
**Отключение и временная блокировка — разные механизмы**, и смешивать их
|
||||
нельзя:
|
||||
|
||||
| | снимается | назначение |
|
||||
| --- | --- | --- |
|
||||
| `disabled` | только руками оператора | отзыв доступа |
|
||||
| `banned_until` | истекает сам | временная блокировка |
|
||||
|
||||
Поэтому `DisconnectPeers` не пишет в базу вовсе, включение пира не сбрасывает
|
||||
`banned_until`, а снятие блокировки не включает отключённого пира.
|
||||
|
||||
**Состояние службы по systemd в этом пути не участвует.** `util.Exec`
|
||||
схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не
|
||||
удалось» в одну ошибку, поэтому `Hysteria2IsRunning` не является основанием ни
|
||||
для отказа операции, ни для её пропуска. Ответ даёт само обращение к Traffic
|
||||
Stats API.
|
||||
|
||||
### Ограничение устройств проверяется fail-closed
|
||||
|
||||
`maxDevices` проверяется по `/online` Traffic Stats API, который возвращает
|
||||
число экземпляров клиента Hysteria — то есть именно «устройства», а не число
|
||||
proxy-потоков.
|
||||
|
||||
Недоступность этого API **отклоняет подключение** и пишет запись уровня
|
||||
`error`. Выбор направления осознанный: запрос авторизации приходит от самой
|
||||
Hysteria, значит она жива, а её Traffic Stats API слушает loopback внутри того
|
||||
же процесса — его недоступность является аномалией, а не штатным состоянием.
|
||||
Обратный выбор молча снимал бы объявленный в панели лимит со всех пиров сразу,
|
||||
и единственным следом этого была бы строка `warn` в журнале.
|
||||
|
||||
У `maxDevices` есть `min=1`, безлимита не бывает, поэтому такой отказ
|
||||
затрагивает всех пиров одновременно. Это ожидаемое поведение, а не деградация:
|
||||
доступность Traffic Stats API входит в install/doctor smoke.
|
||||
|
||||
Путь ОТОБРАЖЕНИЯ остаётся терпимым: дашборд и признак `online` в списке пиров
|
||||
показывают пустую картину, когда служба остановлена, — это честный ответ на
|
||||
вопрос «кто сейчас на связи».
|
||||
|
||||
### Что нельзя делать
|
||||
|
||||
- собирать admin-компонент на target server;
|
||||
@@ -533,6 +617,10 @@ upstream выберет для нового секрета. Список мар
|
||||
- раздувать оркестратор из-за особенностей панели;
|
||||
- использовать HY2XS admin как updater бинаря Hysteria2;
|
||||
- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API;
|
||||
- считать `disabled=1` завершённым отзывом доступа без `/kick`;
|
||||
- откатывать `disabled` из-за неудачи `/kick`;
|
||||
- писать `banned_until` из пути отключения пира;
|
||||
- пропускать проверку лимита устройств, когда Traffic Stats API не ответил;
|
||||
- экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
|
||||
- выгружать конфиг с секретами в открытом виде.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Контракты панели
|
||||
|
||||
Три свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
|
||||
Свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
|
||||
потому ломались молча. Каждое из них закреплено тестом
|
||||
(`tools/test/frontend-*.test.ts`) и гейтом приёмки.
|
||||
|
||||
@@ -134,7 +134,82 @@
|
||||
|
||||
---
|
||||
|
||||
## 4. Атрибуция
|
||||
## 4. Таблицы журнала
|
||||
|
||||
**Правило.** Колонка журнала объявляет свою ширину: служебные — через `width`,
|
||||
содержательная — через `min-width`.
|
||||
|
||||
Без этого Element Plus делит доступную ширину между колонками практически
|
||||
поровну. У журнала колонок три, поэтому уровень и время получали по трети
|
||||
строки, а сообщение — единственное содержимое журнала — тоже треть.
|
||||
|
||||
**Сообщение переносится, а не обрезается.** У Hysteria в `msg` приезжает
|
||||
диагностический JSON; строка, обрезанная многоточием, не отвечает ни на один
|
||||
вопрос, ради которого страницу открыли.
|
||||
|
||||
**Обе страницы журнала построены на одном компоненте**
|
||||
(`components/LogViewer`). Они были побайтово одинаковы и несли одни и те же три
|
||||
дефекта в двух экземплярах — ширины, обработку отказа выгрузки и форму ответа.
|
||||
Собственная `el-table-column` на странице журнала запрещена гейтом приёмки.
|
||||
|
||||
---
|
||||
|
||||
## 5. Выгрузка файлов
|
||||
|
||||
**Правило.** Сборка ссылки на скачивание существует в панели в единственном
|
||||
экземпляре — `utils/download.ts`. Единственность проверяется контрактным
|
||||
тестом по вхождению `createObjectURL`.
|
||||
|
||||
Копий было четыре, и все успели разойтись. Две из них ставили сетевой запрос
|
||||
ПЕРЕД `try`:
|
||||
|
||||
```ts
|
||||
const response = await exportApi(...); // отказ сюда не попадает
|
||||
try { ... } catch (e) { /* empty */ }
|
||||
```
|
||||
|
||||
то есть отказ самого запроса не ловился вовсе, а всё внутри глушилось молча:
|
||||
оператор не получал ни файла, ни причины. Третья падала на `split(...)` при
|
||||
отсутствующем `Content-Disposition` — и это исключение тоже глушилось.
|
||||
|
||||
**Отказ выгрузки показывает сама страница.** Бинарный ответ не проходит через
|
||||
общий разбор конверта: у `Blob` нет полей `code` и `errors`, поэтому
|
||||
перехватчик по нему фразы не даст.
|
||||
|
||||
---
|
||||
|
||||
## 6. Меню действий над строкой
|
||||
|
||||
**Правило.** Пункты `el-dropdown` объявляют `command`; обработчик — один, на
|
||||
`el-dropdown`.
|
||||
|
||||
`@click` на каждом пункте не запрещён самим Element Plus, но `command` является
|
||||
штатным контрактом именно для меню действий, и при нём невозможно добавить
|
||||
пункт, забыв его подключить. Обе половины проверяются контрактным тестом:
|
||||
наличие `@command` и отсутствие `@click` на пунктах.
|
||||
|
||||
**Частичный результат операции отличается от отказа кодом.** Отзыв доступа к
|
||||
VPN состоит из двух половин — записи в базе и разрыва активной сессии, — и
|
||||
первая может примениться без второй. Панель обязана распознать
|
||||
`peer_disconnect_failed` по коду, показать его предупреждением, а не ошибкой, и
|
||||
ОБНОВИТЬ строку: состояние в базе уже изменилось. Показ его как обычной ошибки
|
||||
подтолкнул бы оператора к выводу, прямо противоположному истине.
|
||||
|
||||
---
|
||||
|
||||
## 7. Подтверждения
|
||||
|
||||
**Правило.** Отмена подтверждения — это ответ оператора, а не ошибка.
|
||||
|
||||
`ElMessageBox` отклоняет промис при нажатии «Отмена». `await
|
||||
ElMessageBox.confirm(...)` без разбора отказа оставляет необработанное
|
||||
отклонение промиса на каждую отмену. Единственный прямой вызов на странице
|
||||
пиров живёт внутри `confirmAction`, переводящей отмену в обычное `false`; это
|
||||
закреплено тестом.
|
||||
|
||||
---
|
||||
|
||||
## 8. Атрибуция
|
||||
|
||||
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
|
||||
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
||||
|
||||
@@ -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 -- '<el-table-column' \
|
||||
|| fail "acceptance: the ${page} log page carries its own table again"
|
||||
done
|
||||
code_has apps/frontend/src/components/LogViewer/index.vue -E -- 'min-width="[0-9]+"' \
|
||||
|| fail "acceptance: log table columns must declare explicit widths"
|
||||
|
||||
log_step "Acceptance: file download is built in exactly one place"
|
||||
# Последовательность Blob/createObjectURL/ссылка/клик жила в четырёх копиях,
|
||||
# и все успели разойтись: две из них ставили сетевой запрос ВНЕ try и
|
||||
# глушили причину пустым catch.
|
||||
local download_hits
|
||||
download_hits="$(code_mentions_in 'createObjectURL' apps/frontend/src)"
|
||||
[ "$download_hits" = "apps/frontend/src/utils/download.ts" ] \
|
||||
|| fail "acceptance: file download must live in utils/download.ts alone. Found: $download_hits"
|
||||
}
|
||||
|
||||
# Границы транзакции операции: снятие guard'а и взаимное исключение операций.
|
||||
run_transaction_boundary_acceptance() {
|
||||
log_step "Acceptance: disarming the firewall guard is proven, not announced"
|
||||
|
||||
@@ -241,3 +241,235 @@ describe("секрет пира", () => {
|
||||
expect(form).toContain("await savePeerApi(dataForm)");
|
||||
});
|
||||
});
|
||||
|
||||
function readSource(...segments: string[]): string {
|
||||
return fs.readFileSync(path.join(FRONTEND_SRC, ...segments), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Содержимое файла без комментариев.
|
||||
*
|
||||
* Проверять положено КОД, а не упоминания. Комментарий, объясняющий, почему
|
||||
* конструкции здесь больше нет, обязан называть её по имени, иначе он
|
||||
* бесполезен, — и проверка «такого вызова в файле нет» начинает падать ровно
|
||||
* на той документации, которая описывает выполненную ею же работу.
|
||||
* Отбрасываются строчные `//` и строки тела блочных комментариев `*`.
|
||||
*/
|
||||
function codeOf(source: string): string {
|
||||
return source
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*(\/\/|\/?\*)/.test(line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const LOG_PAGES = [
|
||||
["views", "log", "system", "index.vue"],
|
||||
["views", "log", "hysteria", "index.vue"],
|
||||
];
|
||||
|
||||
describe("страницы журнала", () => {
|
||||
// Обе страницы были побайтово одинаковы и несли одни и те же три дефекта в
|
||||
// двух экземплярах: колонки без ширин, сетевой запрос ВНЕ try и пустой
|
||||
// catch. Починка в двух местах разошлась бы снова.
|
||||
test("обе страницы построены на общем LogViewer", () => {
|
||||
for (const page of LOG_PAGES) {
|
||||
const source = readSource(...page);
|
||||
expect(source).toContain("@/components/LogViewer/index.vue");
|
||||
// Собственной таблицы у страницы быть не должно: ширины колонок живут в
|
||||
// одном месте, иначе они снова разъедутся.
|
||||
expect(source).not.toContain("<el-table-column");
|
||||
}
|
||||
});
|
||||
|
||||
test("ширины колонок журнала заданы явно", () => {
|
||||
const viewer = readSource("components", "LogViewer", "index.vue");
|
||||
|
||||
// Без width/min-width Element Plus делит ширину практически поровну, и
|
||||
// сообщение — единственное содержимое журнала — получает треть строки.
|
||||
const columns = [...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g)].map(
|
||||
(match) => match[0]
|
||||
);
|
||||
expect(columns.length).toBeGreaterThanOrEqual(3);
|
||||
for (const column of columns) {
|
||||
expect(column).toMatch(/\b(width|min-width)=/);
|
||||
}
|
||||
|
||||
// Колонка сообщения растягивается за счёт остатка, служебные — фиксированы.
|
||||
expect(viewer).toMatch(/prop="msg"[\s\S]*?min-width="\d+"/);
|
||||
expect(viewer).toMatch(/prop="level"[\s\S]*?width="\d+"/);
|
||||
expect(viewer).toMatch(/prop="time"[\s\S]*?width="\d+"/);
|
||||
|
||||
// Диагностический JSON Hysteria переносится, а не обрезается многоточием.
|
||||
expect(viewer).toContain("pre-wrap");
|
||||
});
|
||||
|
||||
// Регрессия LOG-03. Было:
|
||||
//
|
||||
// const response = await exportLogApi(...); // запрос ВНЕ try
|
||||
// try { ... } catch (e) { /* empty */ }
|
||||
//
|
||||
// Отказ самого запроса этим catch не ловился вовсе, а всё внутри глушилось
|
||||
// молча: оператор не получал ни файла, ни причины.
|
||||
test("выгрузка журнала выполняется внутри try и сообщает об отказе", () => {
|
||||
const viewer = readSource("components", "LogViewer", "index.vue");
|
||||
|
||||
const exportCall = viewer.indexOf("await exportLogApi");
|
||||
const tryBlock = viewer.indexOf("try {");
|
||||
expect(exportCall).toBeGreaterThan(-1);
|
||||
expect(tryBlock).toBeGreaterThan(-1);
|
||||
expect(tryBlock).toBeLessThan(exportCall);
|
||||
|
||||
// Пустой catch — это и есть проглатывание причины.
|
||||
expect(viewer).not.toMatch(/catch[^)]*\)?\s*\{\s*(\/\*[\s\S]*?\*\/)?\s*\}/);
|
||||
expect(viewer).toContain("log.exportFailed");
|
||||
});
|
||||
|
||||
// Сборка ссылки на скачивание жила в трёх копиях и все три успели
|
||||
// разойтись. Копия обязана быть одна.
|
||||
test("скачивание файла собрано в одном месте", () => {
|
||||
const owners: string[] = [];
|
||||
for (const file of sourceFiles(FRONTEND_SRC)) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
if (source.includes("createObjectURL")) {
|
||||
owners.push(path.relative(FRONTEND_SRC, file).replace(/\\/g, "/"));
|
||||
}
|
||||
}
|
||||
expect(owners).toEqual(["utils/download.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("действия над пиром", () => {
|
||||
const peerList = () => readSource("views", "peer", "list", "index.vue");
|
||||
|
||||
// Регрессия UX-06: пункта быстрого включения/отключения в меню не было
|
||||
// вовсе, хотя это самое частое действие оператора.
|
||||
test("меню строки содержит быстрое включение/отключение", () => {
|
||||
const source = peerList();
|
||||
expect(source).toContain('command="toggle-disabled"');
|
||||
expect(source).toContain("peer.enablePeer");
|
||||
expect(source).toContain("peer.disablePeer");
|
||||
});
|
||||
|
||||
// el-dropdown объявляет command штатным способом описывать меню действий:
|
||||
// команда приходит в одно место, и добавить пункт, забыв его подключить,
|
||||
// становится невозможно.
|
||||
test("меню строки построено на command, а не на click по пунктам", () => {
|
||||
const source = peerList();
|
||||
expect(source).toContain("@command=");
|
||||
|
||||
const items = [...source.matchAll(/<el-dropdown-item[\s\S]*?>/g)].map(
|
||||
(match) => match[0]
|
||||
);
|
||||
expect(items.length).toBeGreaterThanOrEqual(6);
|
||||
for (const item of items) {
|
||||
expect(item).toContain("command=");
|
||||
expect(item).not.toContain("@click");
|
||||
}
|
||||
});
|
||||
|
||||
// Регрессия UX-07: подсказка без ограничения ширины растягивалась почти на
|
||||
// весь экран одной строкой. Ограничение ставится штатным popper-style, а не
|
||||
// глобальным CSS.
|
||||
test("широкая подсказка ограничена popper-style", () => {
|
||||
const source = peerList();
|
||||
expect(source).toContain(":popper-style=");
|
||||
expect(source).toMatch(/maxWidth:\s*"\d+px"/);
|
||||
});
|
||||
|
||||
// Регрессия UX-09: ElMessageBox отклоняет промис при нажатии «Отмена», и
|
||||
// `await ElMessageBox.confirm(...)` без разбора отказа оставлял
|
||||
// необработанное отклонение промиса на каждую отмену.
|
||||
test("отмена подтверждения не считается ошибкой", () => {
|
||||
// Считается КОД, а не упоминания: комментарий, объясняющий, почему прямой
|
||||
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
|
||||
// бесполезен. Это тот же урок, что записан в code_without_comments в
|
||||
// acceptance.sh, и он повторился прямо на этой проверке.
|
||||
const confirms = [...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g)];
|
||||
// Единственный вызов — внутри confirmAction, который переводит отмену в
|
||||
// обычное `false`.
|
||||
expect(confirms.length).toBe(1);
|
||||
expect(peerList()).toContain("async function confirmAction");
|
||||
expect(peerList()).toContain("await confirmAction(");
|
||||
});
|
||||
|
||||
// Частичный результат отзыва доступа — не отказ операции: запрет новых
|
||||
// подключений уже записан. Панель обязана отличать его по коду и обновлять
|
||||
// строку, а не показывать «не сработало».
|
||||
test("частичный результат отзыва доступа разбирается по коду", () => {
|
||||
const source = peerList();
|
||||
expect(source).toContain("ERR_CODE.peerDisconnectFailed");
|
||||
expect(source).toContain("ElMessage.warning");
|
||||
});
|
||||
|
||||
// Срок временной блокировки называется оператору: раньше `Date.now() + час`
|
||||
// был зашит в обработчик и не сообщался ни до, ни после.
|
||||
test("временная блокировка подтверждается и называет срок", () => {
|
||||
const source = peerList();
|
||||
expect(source).toContain("peer.kickConfirm");
|
||||
expect(source).toContain("KICK_DURATION_MS");
|
||||
});
|
||||
|
||||
// Об одном результате оператор узнаёт ОДИН раз. Действия строки сообщают
|
||||
// исход сами, поэтому общий перехватчик обязан молчать: иначе рядом с
|
||||
// предупреждением о частичном отзыве доступа встанет ошибка от перехватчика —
|
||||
// два противоречащих друг другу сообщения об одном событии.
|
||||
test("действия строки не получают второго тоста от перехватчика", () => {
|
||||
const api = codeOf(readSource("api", "peer", "index.ts"));
|
||||
|
||||
const selfReporting = [
|
||||
"deletePeerApi",
|
||||
"resetPeerTrafficApi",
|
||||
"releaseKickPeerApi",
|
||||
"kickPeerApi",
|
||||
"updatePeerApi",
|
||||
"savePeerApi",
|
||||
];
|
||||
|
||||
for (const name of selfReporting) {
|
||||
const start = api.indexOf(`export function ${name}`);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const rest = api.slice(start + 1);
|
||||
const end = rest.indexOf("\nexport function ");
|
||||
const body = end < 0 ? rest : rest.slice(0, end);
|
||||
expect(body).toContain("skipErrorToast: true");
|
||||
}
|
||||
});
|
||||
|
||||
// Флаг относится только к отказу API. У транспортного отказа конверта нет,
|
||||
// страница о нём сказать ничего не может и молчит — а пока флаг гасил и эту
|
||||
// ветку, обрыв соединения при сохранении, удалении или отзыве доступа не
|
||||
// показывал оператору ничего вовсе.
|
||||
test("транспортный отказ показывается всегда", () => {
|
||||
const request = codeOf(readSource("utils", "request.ts"));
|
||||
|
||||
const handler = request.slice(request.indexOf("(error: AxiosError)"));
|
||||
expect(handler).toContain("ElMessage.error(message)");
|
||||
expect(handler).not.toContain("skipErrorToast");
|
||||
});
|
||||
});
|
||||
|
||||
describe("подсказки формы пира", () => {
|
||||
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
||||
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
||||
// пробовать разрешённые символы.
|
||||
test("подсказка имени описывает действующий набор символов", () => {
|
||||
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
|
||||
// описывать именно его, а не набор из предыдущего поколения правила.
|
||||
const rule = fs.readFileSync(
|
||||
path.join(REPO_ROOT, "apps", "service", "peer_import.go"),
|
||||
"utf8"
|
||||
);
|
||||
const declared = rule.match(/PeerNameCharset\s*=\s*`([^`]+)`/);
|
||||
expect(declared).not.toBeNull();
|
||||
|
||||
// «a-z A-Z 0-9 !@#$%^&*()_+-=» -> «!@#$%^&*()_+-=»
|
||||
const punctuation = declared![1].split(/\s+/).at(-1)!;
|
||||
expect(punctuation).toContain("!@#");
|
||||
|
||||
for (const dictionary of [ru, en] as const) {
|
||||
const hint = (dictionary as any).peer?.form?.nameHint ?? "";
|
||||
expect(hint).toContain(punctuation);
|
||||
expect(hint).toContain("32");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user