Files
HY2XS_flamy/apps/service/journal.go
T
founder cb20d8d28f 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
2026-09-02 23:24:01 +05:00

324 lines
12 KiB
Go
Raw 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 service
import (
"bufio"
"encoding/json"
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
"time"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
)
type journalctlLogLine struct {
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 {
if value <= 0 {
return fallback
}
if value > max {
return max
}
return value
}
func ReadHysteriaJournalLogs(numLine int) ([]vo.LogHysteria2Vo, int, error) {
lines := normalizeJournalLineLimit(numLine, 100, 300)
output, err := util.Exec(fmt.Sprintf("journalctl --no-pager -u hysteria-server.service -n %d -o json", lines))
if err != nil {
return nil, 0, err
}
result := make([]vo.LogHysteria2Vo, 0, lines)
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
item := journalctlLogLine{}
if err = json.Unmarshal([]byte(line), &item); err != nil {
continue
}
result = append(result, parseHysteriaJournalRecord(item))
}
if scanErr := scanner.Err(); scanErr != nil {
return nil, 0, scanErr
}
return result, len(result), nil
}
// ExportAdminLog отдаёт журнал самой админки, вырезав из него секреты.
//
// Раньше этот файл выгружался как есть, через c.File: чужой журнал продукт
// санитизировал, а собственный — нет. Обоснования у асимметрии не было, а цена
// известна поимённо: пока bootstrap-пароль администратора печатался в лог
// warning'ом, обычная кнопка «выгрузить журнал» отдавала его открытым текстом.
// Сам warning убран (см. dao/sqlite.go), но защита от следующего такого места
// должна стоять и на выходе.
func ExportAdminLog() (string, error) {
content, err := os.ReadFile(constant.SystemLogPath)
if err != nil {
return "", err
}
return SanitizeLogText(string(content)), nil
}
// ExportHysteriaJournalLogs отдаёт журнал Hysteria оператору.
//
// Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные
// выгружаемые артефакты: HY2_AUTH_URL несёт access_token, и upstream волен
// упомянуть его в сообщении об ошибке обращения к auth-backend.
func ExportHysteriaJournalLogs(numLine int) (string, error) {
lines := normalizeJournalLineLimit(numLine, 5000, 10000)
output, err := util.Exec(fmt.Sprintf("journalctl --no-pager -u hysteria-server.service -n %d -o short-iso", lines))
if err != nil {
return "", err
}
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(message) == "" {
return vo.LogHysteria2Vo{
Level: fallbackLevel,
Msg: "",
Time: fallbackTime,
}
}
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,
}
}
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 ""
}
}
func convertJournalTimestamp(raw string) string {
if raw == "" {
return ""
}
us, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
t := time.UnixMicro(us)
return t.Format("2006-01-02 15:04:05")
}
func mapJournalPriorityToLevel(priority string) string {
switch strings.TrimSpace(priority) {
case "0", "1", "2", "3":
return "error"
case "4":
return "warn"
default:
return "info"
}
}