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