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