Files
founder 162759c599 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.
2026-09-01 17:17:17 +05:00

117 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}