fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним, поэтому сессия, установленная по отозванным учётным данным, была неотличима от законной, и цикл учёта не имел признака, по которому её следовало завершить. У состояния есть путь без единой неудачи — Hysteria регистрирует соединение в Traffic Stats API только после возврата backend-auth, поэтому успешный /kick может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт по старому, пережившая сессия становится orphan. Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4, админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала лимит устройств, учёт трафика и принудительное отключение разом. Адрес зафиксирован, а расхождение файла с ним админка называет. Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним значением, а доступность Traffic Stats API выводилась из него же. Журнал Hysteria разбирается в фактическом формате upstream (time — дробное число), страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в браузер, санитайзер выгрузки следует по YAML-якорям. Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
This commit is contained in:
+195
-23
@@ -4,19 +4,61 @@ import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/util"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
type journalctlLogLine struct {
|
||||
Message string `json:"MESSAGE"`
|
||||
Priority string `json:"PRIORITY"`
|
||||
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
|
||||
Message journalMessage `json:"MESSAGE"`
|
||||
Priority string `json:"PRIORITY"`
|
||||
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
|
||||
}
|
||||
|
||||
// journalMessage — поле MESSAGE журнала, которое journald отдаёт ДВУМЯ разными
|
||||
// способами.
|
||||
//
|
||||
// Обычно это строка. Но если сообщение не является корректным UTF-8, `-o json`
|
||||
// отдаёт его массивом байтовых значений — это документированное поведение
|
||||
// journald, а не аномалия. Прежний `Message string` на такой записи ронял
|
||||
// разбор ВСЕЙ строки, и она молча выпадала из журнала: обработчик делал
|
||||
// `continue`. То есть ровно те строки, ради которых журнал открывают чаще
|
||||
// всего — с испорченным выводом чужого процесса, — панель не показывала вовсе.
|
||||
type journalMessage string
|
||||
|
||||
func (m *journalMessage) UnmarshalJSON(data []byte) error {
|
||||
var asString string
|
||||
if err := json.Unmarshal(data, &asString); err == nil {
|
||||
*m = journalMessage(asString)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Массив чисел разбирается через []int, а не []byte: для []byte
|
||||
// encoding/json ожидает base64-строку и на JSON-массиве откажет.
|
||||
var asBytes []int
|
||||
if err := json.Unmarshal(data, &asBytes); err == nil {
|
||||
raw := make([]byte, 0, len(asBytes))
|
||||
for _, value := range asBytes {
|
||||
if value < 0 || value > 255 {
|
||||
continue
|
||||
}
|
||||
raw = append(raw, byte(value))
|
||||
}
|
||||
*m = journalMessage(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Поле отсутствует или имеет неизвестную форму: сообщения нет, но сама
|
||||
// запись остаётся — у неё есть время и приоритет.
|
||||
*m = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeJournalLineLimit(value int, fallback int, max int) int {
|
||||
@@ -91,11 +133,39 @@ func ExportHysteriaJournalLogs(numLine int) (string, error) {
|
||||
return SanitizeLogText(output), nil
|
||||
}
|
||||
|
||||
// Структурный журнал Hysteria разбирается ПО ФАКТИЧЕСКОМУ формату 2.12.2.
|
||||
//
|
||||
// Что было. Запись складывалась прямым json.Unmarshal в vo.LogHysteria2Vo, у
|
||||
// которого `Time string`. JSON-логгер Hysteria объявлен так:
|
||||
//
|
||||
// TimeKey: "time", LevelKey: "level", MessageKey: "msg",
|
||||
// EncodeTime: zapcore.EpochMillisTimeEncoder
|
||||
//
|
||||
// то есть `time` — это ЧИСЛО, причём дробное: энкодер делит наносекунды на
|
||||
// миллисекунду и печатает float64 (`1788321234567.1235`). Разбор падал на
|
||||
// каждой без исключения строке:
|
||||
//
|
||||
// cannot unmarshal number into Go struct field ... of type string
|
||||
//
|
||||
// После чего срабатывал fallback и панель показывала сырой JSON целиком. То
|
||||
// есть `HYSTERIA_LOG_FORMAT=json` в юните был включён, а структурой никто не
|
||||
// пользовался: страница журнала выглядела ровно так же, как без него, только
|
||||
// менее читаемо.
|
||||
//
|
||||
// Замена типа поля на int64 проблему НЕ решает по двум причинам: значение
|
||||
// дробное, а кроме level/time/msg upstream кладёт в запись диагностический
|
||||
// контекст (`listen`, `addr`, `id`, `error`, `tx`, `rx`, …), который при
|
||||
// разборе в три известных поля просто выбрасывался бы. Именно эти поля и
|
||||
// отвечают на вопрос, ради которого журнал открывают.
|
||||
//
|
||||
// Поэтому разбор идёт через map: известные ключи заполняют колонки, остальные
|
||||
// дописываются к сообщению как `msg [key=value …]` в устойчивом порядке.
|
||||
func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
||||
fallbackTime := convertJournalTimestamp(item.RealtimeTimestamp)
|
||||
fallbackLevel := mapJournalPriorityToLevel(item.Priority)
|
||||
message := string(item.Message)
|
||||
|
||||
if strings.TrimSpace(item.Message) == "" {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return vo.LogHysteria2Vo{
|
||||
Level: fallbackLevel,
|
||||
Msg: "",
|
||||
@@ -103,25 +173,127 @@ func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
||||
}
|
||||
}
|
||||
|
||||
parsed := vo.LogHysteria2Vo{}
|
||||
if err := json.Unmarshal([]byte(item.Message), &parsed); err == nil {
|
||||
if parsed.Level == "" {
|
||||
parsed.Level = fallbackLevel
|
||||
fields := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(message), &fields); err != nil {
|
||||
// Не JSON вовсе: журнал Hysteria в console-формате либо посторонняя
|
||||
// строка от рантайма. Показывается как есть.
|
||||
return vo.LogHysteria2Vo{
|
||||
Level: fallbackLevel,
|
||||
Msg: SanitizeLogText(message),
|
||||
Time: fallbackTime,
|
||||
}
|
||||
if parsed.Time == "" {
|
||||
parsed.Time = fallbackTime
|
||||
}
|
||||
if parsed.Msg == "" {
|
||||
parsed.Msg = item.Message
|
||||
}
|
||||
parsed.Msg = SanitizeLogText(parsed.Msg)
|
||||
return parsed
|
||||
}
|
||||
|
||||
return vo.LogHysteria2Vo{
|
||||
Level: fallbackLevel,
|
||||
Msg: SanitizeLogText(item.Message),
|
||||
Time: fallbackTime,
|
||||
record := vo.LogHysteria2Vo{
|
||||
Level: journalStringField(fields, "level"),
|
||||
Msg: journalStringField(fields, "msg"),
|
||||
Time: hysteriaLogTime(fields["time"]),
|
||||
}
|
||||
if record.Level == "" {
|
||||
record.Level = fallbackLevel
|
||||
}
|
||||
if record.Time == "" {
|
||||
// Время journald — канонический запасной вариант: оно есть у КАЖДОЙ
|
||||
// записи и относится к тому же событию.
|
||||
record.Time = fallbackTime
|
||||
}
|
||||
if record.Msg == "" {
|
||||
// JSON без `msg` — не запись Hysteria. Терять её нельзя.
|
||||
record.Msg = message
|
||||
}
|
||||
record.Msg = SanitizeLogText(record.Msg + hysteriaLogContext(fields))
|
||||
return record
|
||||
}
|
||||
|
||||
// hysteriaLogReservedFields — ключи, которые уже показаны собственными
|
||||
// колонками либо не несут смысла для оператора.
|
||||
//
|
||||
// `logger` объявлен в EncoderConfig Hysteria (NameKey), но именованных
|
||||
// логгеров у сервера нет, поэтому в записи он не появляется; ключ перечислен,
|
||||
// чтобы будущее появление имени не поехало в текст сообщения.
|
||||
var hysteriaLogReservedFields = map[string]struct{}{
|
||||
"level": {},
|
||||
"msg": {},
|
||||
"time": {},
|
||||
"logger": {},
|
||||
}
|
||||
|
||||
// hysteriaLogContext собирает структурный контекст записи в устойчивом порядке.
|
||||
//
|
||||
// Порядок ключей в map случаен, поэтому без сортировки одна и та же запись
|
||||
// выглядела бы по-разному при каждом обновлении страницы.
|
||||
func hysteriaLogContext(fields map[string]any) string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
if _, reserved := hysteriaLogReservedFields[key]; reserved {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", key, journalFieldValue(fields[key])))
|
||||
}
|
||||
return " [" + strings.Join(parts, " ") + "]"
|
||||
}
|
||||
|
||||
// journalFieldValue приводит значение поля к однострочному виду.
|
||||
func journalFieldValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return typed
|
||||
case bool:
|
||||
return strconv.FormatBool(typed)
|
||||
case float64:
|
||||
return formatJSONNumber(typed)
|
||||
default:
|
||||
// Вложенный объект или массив: JSON компактнее и однозначнее, чем
|
||||
// проценты Go-форматирования.
|
||||
encoded, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", typed)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
// formatJSONNumber печатает число без экспоненты и без хвоста `.0`: в журнале
|
||||
// оператор ожидает `443`, а не `4.43e+02`.
|
||||
func formatJSONNumber(value float64) string {
|
||||
if value == math.Trunc(value) && math.Abs(value) < 1e15 {
|
||||
return strconv.FormatInt(int64(value), 10)
|
||||
}
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func journalStringField(fields map[string]any, key string) string {
|
||||
if value, ok := fields[key].(string); ok {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hysteriaLogTime переводит `time` записи в отображаемое время.
|
||||
//
|
||||
// Число трактуется как epoch millis — так его пишет EpochMillisTimeEncoder.
|
||||
// Строка принимается как есть: console-формат и будущая смена энкодера дают
|
||||
// RFC3339, и переписывать его продукт не обязан.
|
||||
func hysteriaLogTime(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
millis := int64(typed)
|
||||
return time.UnixMilli(millis).Format("2006-01-02 15:04:05")
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user