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)
|
||||
|
||||
Reference in New Issue
Block a user