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:
2026-09-02 23:24:01 +05:00
parent 8dcb50a07c
commit cb20d8d28f
66 changed files with 4981 additions and 2684 deletions
+195 -23
View File
@@ -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 ""
}
}