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