162759c599
Разбор кода на 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.
135 lines
5.8 KiB
Go
135 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|