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" } }