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= — то есть при каждом // подключении пира. Пока логировался 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= при каждом подключении // пира; журнал отдаётся оператору через 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()) }