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.
117 lines
5.3 KiB
Go
117 lines
5.3 KiB
Go
package middleware
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/sirupsen/logrus"
|
||
"gopkg.in/natefinch/lumberjack.v2"
|
||
"hy2xs-admin/model/constant"
|
||
)
|
||
|
||
func InitLog() {
|
||
logrus.SetOutput(&lumberjack.Logger{
|
||
Filename: constant.SystemLogPath,
|
||
MaxSize: 1,
|
||
MaxBackups: 2,
|
||
MaxAge: 30,
|
||
Compress: true,
|
||
LocalTime: true,
|
||
})
|
||
logrus.SetFormatter(&logrus.JSONFormatter{TimestampFormat: "2006-01-02 15:04:05"})
|
||
logrus.SetLevel(logrus.InfoLevel)
|
||
}
|
||
|
||
// requestQueryKeys возвращает ИМЕНА query-параметров запроса, без значений.
|
||
//
|
||
// Для диагностики важно, был ли параметр передан, а не что в нём лежало.
|
||
// Список сортируется, чтобы одинаковые запросы давали одинаковую строку в
|
||
// логе и её можно было группировать.
|
||
func requestQueryKeys(c *gin.Context) string {
|
||
query := c.Request.URL.Query()
|
||
if len(query) == 0 {
|
||
return ""
|
||
}
|
||
keys := make([]string, 0, len(query))
|
||
for key := range query {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
return strings.Join(keys, ",")
|
||
}
|
||
|
||
// LogHandler пишет журнал HTTP-запросов админки.
|
||
//
|
||
// В лог попадает ПУТЬ, а не RequestURI.
|
||
//
|
||
// Разница здесь не косметическая. RequestURI включает query string, а Hysteria
|
||
// обращается к машинному endpoint'у как
|
||
// /internal/hysteria/auth?access_token=<machine token> — то есть при каждом
|
||
// подключении пира. Пока логировался RequestURI, действующий machine token
|
||
// оседал открытым текстом в /var/log/hy2xs/hy2xs-admin.log, а этот файл
|
||
// отдаётся оператору через ExportLog и попадает в diagnostics-бандл.
|
||
// Получался постоянный канал утечки секрета в обычной штатной работе, мимо
|
||
// всей структурной редакции, сделанной для конфигов и env.
|
||
//
|
||
// Значения query-параметров не логируются вовсе: список того, «что можно», при
|
||
// таком подходе пришлось бы поддерживать вручную и он неизбежно разошёлся бы с
|
||
// набором маршрутов. Имена параметров сохранены — их для диагностики хватает.
|
||
func LogHandler() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
startTime := time.Now()
|
||
|
||
c.Next()
|
||
|
||
statusCode := c.Writer.Status()
|
||
latencyTime := time.Since(startTime)
|
||
|
||
fields := logrus.Fields{
|
||
"statusCode": statusCode,
|
||
"latencyTime": latencyTime.Milliseconds(),
|
||
"clientIP": c.ClientIP(),
|
||
"reqMethod": c.Request.Method,
|
||
"reqPath": c.Request.URL.Path,
|
||
}
|
||
if keys := requestQueryKeys(c); keys != "" {
|
||
fields["reqQueryKeys"] = keys
|
||
}
|
||
|
||
entry := logrus.WithFields(fields)
|
||
message := RequestLogMessage(c.Request.Method, c.Request.URL.Path, statusCode, latencyTime)
|
||
|
||
if statusCode >= 500 {
|
||
entry.Error(message)
|
||
} else if statusCode >= 400 {
|
||
entry.Warn(message)
|
||
} else {
|
||
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())
|
||
}
|