cb20d8d28f
Отзыв секрета не сходился: `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
324 lines
12 KiB
Go
324 lines
12 KiB
Go
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"
|
||
}
|
||
}
|